Compare commits

...

10 Commits

Author SHA1 Message Date
Aarnav Tale 2229f547a9 chore: v0.3.1 2024-10-03 15:31:03 -04:00
Aarnav Tale 65cc278a59 docs(TALE-33): document debug 2024-10-03 15:30:24 -04:00
Aarnav Tale 1555846df2 fix(TALE-34): use the http coded URL for socket 2024-10-03 15:15:56 -04:00
Aarnav Tale e8c1cadf54 feat(TALE-35): add initial machine key authorization 2024-10-03 11:58:05 -04:00
Aarnav Tale d867769025 chore: update browserlist 2024-10-02 13:41:45 -04:00
Aarnav Tale 1d6066d3f0 feat(TALE-33): add debug logging with DEBUG env 2024-10-02 13:33:39 -04:00
Aarnav Tale 1d821251a9 fix(TALE-34): setting url.protocol does not work anymore 2024-10-02 12:59:42 -04:00
Aarnav Tale a0d6905123 chore: v0.3.0 2024-09-25 16:32:58 -04:00
Aarnav Tale 4095ed2a68 docs: version the docker images 2024-09-25 16:26:19 -04:00
Aarnav Tale 58e98278d1 chore: support dns use_username_in_magic_dns config 2024-09-25 16:22:00 -04:00
25 changed files with 290 additions and 43 deletions
+9
View File
@@ -1,3 +1,12 @@
### 0.3.1 (October 3, 2024)
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
- Added the option for debug logs with the `DEBUG=true` environment variable.
### 0.3.0 (September 25, 2024)
- Bumped the minimum supported version of Headscale to 0.23.
- Updated the UI to respect `dns.use_username_in_magic_dns`.
### 0.2.4 (August 24, 2024) ### 0.2.4 (August 24, 2024)
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively. - Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
- Removed the `ACL_FILE` environment variable since it's no longer needed. - Removed the `ACL_FILE` environment variable since it's no longer needed.
+18 -6
View File
@@ -24,6 +24,7 @@ export default createIntegration<Context>({
isAvailable: async (context) => { isAvailable: async (context) => {
// Check for the HEADSCALE_CONTAINER environment variable first // Check for the HEADSCALE_CONTAINER environment variable first
// to avoid unnecessary fetching of the Docker socket // to avoid unnecessary fetching of the Docker socket
log.debug('INTG', 'Checking Docker integration availability')
context.container = process.env.HEADSCALE_CONTAINER context.container = process.env.HEADSCALE_CONTAINER
?.trim() ?.trim()
.toLowerCase() .toLowerCase()
@@ -54,16 +55,19 @@ export default createIntegration<Context>({
// The API is available as an HTTP endpoint and this // The API is available as an HTTP endpoint and this
// will simplify the fetching logic in undici // will simplify the fetching logic in undici
if (url.protocol === 'tcp:') { if (url.protocol === 'tcp:') {
url.protocol = 'http:' // Apparently setting url.protocol doesn't work anymore?
const fetchU = url.href.replace(url.protocol, 'http:')
try { try {
log.info('INTG', 'Checking API: %s', url.href) log.info('INTG', 'Checking API: %s', fetchU)
await fetch(new URL('/v1.30/version', url).href) await fetch(new URL('/v1.30/version', fetchU).href)
} catch { } catch (error) {
log.debug('INTG', 'Failed to connect to Docker API', error)
log.error('INTG', 'Failed to connect to Docker API') log.error('INTG', 'Failed to connect to Docker API')
return false return false
} }
context.client = new Client(url.href) context.client = new Client(fetchU)
} }
// Check if the socket is accessible // Check if the socket is accessible
@@ -73,7 +77,8 @@ export default createIntegration<Context>({
url.pathname, url.pathname,
) )
await access(url.pathname, constants.R_OK) await access(url.pathname, constants.R_OK)
} catch { } catch (error) {
log.debug('INTG', 'Failed to access Docker socket: %s', error)
log.error('INTG', 'Failed to access Docker socket: %s', log.error('INTG', 'Failed to access Docker socket: %s',
path, path,
) )
@@ -97,6 +102,12 @@ export default createIntegration<Context>({
let attempts = 0 let attempts = 0
while (attempts <= context.maxAttempts) { while (attempts <= context.maxAttempts) {
log.debug(
'INTG', 'Restarting container: %s (attempt %d)',
context.container,
attempts,
)
const response = await context.client.request({ const response = await context.client.request({
method: 'POST', method: 'POST',
path: `/v1.30/containers/${context.container}/restart`, path: `/v1.30/containers/${context.container}/restart`,
@@ -120,6 +131,7 @@ export default createIntegration<Context>({
attempts = 0 attempts = 0
while (attempts <= context.maxAttempts) { while (attempts <= context.maxAttempts) {
try { try {
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts)
await pull('v1', '') await pull('v1', '')
return return
} catch (error) { } catch (error) {
+17 -1
View File
@@ -26,6 +26,7 @@ export default createIntegration<Context>({
const svcRoot = Config.SERVICEACCOUNT_ROOT const svcRoot = Config.SERVICEACCOUNT_ROOT
try { try {
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot)
const files = await readdir(svcRoot) const files = await readdir(svcRoot)
if (files.length === 0) { if (files.length === 0) {
log.error('INTG', 'Kubernetes service account not found') log.error('INTG', 'Kubernetes service account not found')
@@ -39,6 +40,7 @@ export default createIntegration<Context>({
Config.SERVICEACCOUNT_NAMESPACE_PATH, Config.SERVICEACCOUNT_NAMESPACE_PATH,
] ]
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '))
if (!expectedFiles.every(file => mappedFiles.has(file))) { if (!expectedFiles.every(file => mappedFiles.has(file))) {
log.error('INTG', 'Malformed Kubernetes service account') log.error('INTG', 'Malformed Kubernetes service account')
return false return false
@@ -48,6 +50,7 @@ export default createIntegration<Context>({
return false return false
} }
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot)
const namespace = await readFile( const namespace = await readFile(
Config.SERVICEACCOUNT_NAMESPACE_PATH, Config.SERVICEACCOUNT_NAMESPACE_PATH,
'utf8', 'utf8',
@@ -68,7 +71,13 @@ export default createIntegration<Context>({
return false return false
} }
log.debug('INTG', 'Checking Kubernetes pod %s in namespace %s',
pod,
namespace,
)
try { try {
log.debug('INTG', 'Attempgin to get cluster KubeConfig')
const kc = new KubeConfig() const kc = new KubeConfig()
kc.loadFromCluster() kc.loadFromCluster()
@@ -91,6 +100,7 @@ export default createIntegration<Context>({
kCoreV1Api.basePath, kCoreV1Api.basePath,
) )
log.debug('INTG', 'Reading pod info for %s', pod)
const { response, body } = await kCoreV1Api.readNamespacedPod( const { response, body } = await kCoreV1Api.readNamespacedPod(
pod, pod,
namespace, namespace,
@@ -103,6 +113,7 @@ export default createIntegration<Context>({
return false return false
} }
log.debug('INTG', 'Got pod info: %o', body.spec)
const shared = body.spec?.shareProcessNamespace const shared = body.spec?.shareProcessNamespace
if (shared === undefined) { if (shared === undefined) {
log.error( log.error(
@@ -127,6 +138,7 @@ export default createIntegration<Context>({
} }
} }
log.debug('INTG', 'Looking for namespaced process in /proc')
const dir = resolve('/proc') const dir = resolve('/proc')
try { try {
const subdirs = await readdir(dir) const subdirs = await readdir(dir)
@@ -139,11 +151,14 @@ export default createIntegration<Context>({
const path = join('/proc', dir, 'cmdline') const path = join('/proc', dir, 'cmdline')
try { try {
log.debug('INTG', 'Reading %s', path)
const data = await readFile(path, 'utf8') const data = await readFile(path, 'utf8')
if (data.includes('headscale')) { if (data.includes('headscale')) {
return pid return pid
} }
} catch {} } catch (error) {
log.debug('INTG', 'Failed to read %s: %s', path, error)
}
}) })
const results = await Promise.allSettled(promises) const results = await Promise.allSettled(promises)
@@ -155,6 +170,7 @@ export default createIntegration<Context>({
} }
} }
log.debug('INTG', 'Found Headscale processes: %o', pids)
if (pids.length > 1) { if (pids.length > 1) {
log.error('INTG', 'Found %d Headscale processes: %s', log.error('INTG', 'Found %d Headscale processes: %s',
pids.length, pids.length,
+6 -1
View File
@@ -22,6 +22,7 @@ export default createIntegration<Context>({
return false return false
} }
log.debug('INTG', 'Checking /proc for Headscale process')
const dir = resolve('/proc') const dir = resolve('/proc')
try { try {
const subdirs = await readdir(dir) const subdirs = await readdir(dir)
@@ -34,11 +35,14 @@ export default createIntegration<Context>({
const path = join('/proc', dir, 'cmdline') const path = join('/proc', dir, 'cmdline')
try { try {
log.debug('INTG', 'Reading %s', path)
const data = await readFile(path, 'utf8') const data = await readFile(path, 'utf8')
if (data.includes('headscale')) { if (data.includes('headscale')) {
return pid return pid
} }
} catch {} } catch (error) {
log.error('INTG', 'Failed to read %s: %s', path, error)
}
}) })
const results = await Promise.allSettled(promises) const results = await Promise.allSettled(promises)
@@ -50,6 +54,7 @@ export default createIntegration<Context>({
} }
} }
log.debug('INTG', 'Found Headscale processes: %o', pids)
if (pids.length > 1) { if (pids.length > 1) {
log.error('INTG', 'Found %d Headscale processes: %s', log.error('INTG', 'Found %d Headscale processes: %s',
pids.length, pids.length,
+3 -1
View File
@@ -1,9 +1,10 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react' import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node' import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react' import { useFetcher, useLoaderData } from '@remix-run/react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components' import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
import { setTimeout } from 'node:timers/promises'
import Button from '~/components/Button' import Button from '~/components/Button'
import Code from '~/components/Code' import Code from '~/components/Code'
@@ -75,6 +76,7 @@ export async function action({ request }: ActionFunctionArgs) {
policy: acl, policy: acl,
}) })
await setTimeout(250)
return json({ success: true }) return json({ success: true })
} catch (error) { } catch (error) {
return json({ success: false }, { return json({ success: false }, {
+1 -1
View File
@@ -28,7 +28,7 @@ export default function Modal({ name, disabled }: Properties) {
Devices are accessible at Devices are accessible at
{' '} {' '}
<Code> <Code>
[device].[user].{name} [device].{name}
</Code> </Code>
{' '} {' '}
when Magic DNS is enabled. when Magic DNS is enabled.
+4 -2
View File
@@ -25,7 +25,9 @@ export async function loader() {
const dns = { const dns = {
prefixes: config.prefixes, prefixes: config.prefixes,
magicDns: config.dns.magic_dns, magicDns: config.dns.magic_dns,
baseDomain: config.dns.base_domain, baseDomain: config.dns.use_username_in_magic_dns
? `[user].${config.dns.base_domain}`
: config.dns.base_domain,
nameservers: config.dns.nameservers.global, nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split, splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains, searchDomains: config.dns.search_domains,
@@ -107,7 +109,7 @@ export default function Page() {
on the tailnet. Devices will be accessible at on the tailnet. Devices will be accessible at
{' '} {' '}
<Code> <Code>
[device].[user]. [device].
{data.baseDomain} {data.baseDomain}
</Code> </Code>
{' '} {' '}
@@ -97,6 +97,35 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
} }
} }
case 'register': {
const key = data.get('mkey')?.toString()
const user = data.get('user')?.toString()
if (!key) {
return json({ message: 'No machine key provided' }, {
status: 400,
})
}
if (!user) {
return json({ message: 'No user provided' }, {
status: 400,
})
}
try {
await post('v1/node/register', session.get('hsApiKey')!, {
user, key,
})
return json({ message: 'Machine registered' })
} catch {
return json({ message: 'Failed to register machine' }, {
status: 500,
})
}
}
default: { default: {
return json({ message: 'Invalid method' }, { return json({ message: 'Invalid method' }, {
status: 400, status: 400,
@@ -0,0 +1,115 @@
import { Form, useSubmit } from '@remix-run/react'
import { Dispatch, SetStateAction, useState } from 'react'
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import Select from '~/components/Select'
import Menu from '~/components/Menu'
import { Machine, User } from '~/types'
export interface NewProps {
server: string
users: User[]
}
export default function New(data: NewProps) {
const submit = useSubmit()
const mkeyState = useState(false)
const pkeyState = useState(false)
const [mkey, setMkey] = useState('')
const [user, setUser] = useState(data.users[0].id)
return (
<>
<Dialog>
<Dialog.Panel control={mkeyState}>
{close => (
<>
<Dialog.Title>
Register Machine Key
</Dialog.Title>
<Dialog.Text className='mb-4'>
The machine key is given when you run
{' '}
<Code>
tailscale up --login-server=
</Code>
<Code>
{data.server}
</Code>
{' '}
on your device.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<TextField
label='Machine Key'
placeholder='nodekey:ff.....'
name="mkey"
state={[mkey, setMkey]}
className='my-2 font-mono'
/>
<Select
label="Owner"
name="user"
placeholder="Select a user"
state={[user, setUser]}
>
{data.users.map(user => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
<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}
>
Register
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
<Menu>
<Menu.Button
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
'hover:bg-main-800 dark:hover:bg-main-700',
)}
>
Add Device
</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={mkeyState}>
<ServerIcon className='w-4 h-4 mr-2'/>
Register Machine Key
</Menu.ItemButton>
<Menu.ItemButton control={pkeyState} isDisabled>
<KeyIcon className='w-4 h-4 mr-2'/>
Generate Pre-auth Key
</Menu.ItemButton>
</Menu.Items>
</Menu>
</>
)
}
+6 -4
View File
@@ -31,6 +31,10 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
tags.unshift('Expired') tags.unshift('Expired')
} }
let prefix = magic?.startsWith('[user]')
? magic.replace('[user]', machine.user.name)
: magic
return ( return (
<tr <tr
key={machine.id} key={machine.id}
@@ -102,16 +106,14 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
'justify-between w-full break-keep', 'justify-between w-full break-keep',
)} )}
onPress={async () => { onPress={async () => {
const ip = `${machine.givenName}.${machine.user.name}.${magic}` const ip = `${machine.givenName}.${prefix}`
await navigator.clipboard.writeText(ip) await navigator.clipboard.writeText(ip)
toast('Copied hostname to clipboard') toast('Copied hostname to clipboard')
}} }}
> >
{machine.givenName} {machine.givenName}
. .
{machine.user.name} {prefix}
.
{magic}
<CopyIcon className="w-3 h-3" /> <CopyIcon className="w-3 h-3" />
</Menu.ItemButton> </Menu.ItemButton>
) )
+11 -2
View File
@@ -15,6 +15,7 @@ import { useLiveData } from '~/utils/useLiveData'
import { menuAction } from './action' import { menuAction } from './action'
import MachineRow from './machine' import MachineRow from './machine'
import NewMachine from './dialogs/new'
export async function loader({ request }: LoaderFunctionArgs) { export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie')) const session = await getSession(request.headers.get('Cookie'))
@@ -32,6 +33,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
if (config.dns.magic_dns) { if (config.dns.magic_dns) {
magic = config.dns.base_domain magic = config.dns.base_domain
} }
if (config.dns.use_username_in_magic_dns) {
magic = `[user].${magic}`
}
} }
return { return {
@@ -39,6 +44,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
routes: routes.routes, routes: routes.routes,
users: users.users, users: users.users,
magic, magic,
server: context.headscaleUrl,
} }
} }
@@ -52,7 +58,10 @@ export default function Page() {
return ( return (
<> <>
<h1 className="text-2xl font-medium mb-4">Machines</h1> <div className="flex justify-between items-center">
<h1 className="text-2xl font-medium mb-4">Machines</h1>
<NewMachine server={data.server} users={data.users} />
</div>
<table className="table-auto w-full rounded-lg"> <table className="table-auto w-full rounded-lg">
<thead className="text-gray-500 dark:text-gray-400"> <thead className="text-gray-500 dark:text-gray-400">
<tr className="text-left uppercase text-xs font-bold px-0.5"> <tr className="text-left uppercase text-xs font-bold px-0.5">
@@ -76,7 +85,7 @@ export default function Page() {
based on their name and also at based on their name and also at
{' '} {' '}
<Code> <Code>
[name].[user]. [name].
{data.magic} {data.magic}
</Code> </Code>
</Tooltip> </Tooltip>
-1
View File
@@ -36,7 +36,6 @@ export default function Add({ magic }: Props) {
{' '} {' '}
<Code> <Code>
[machine]. [machine].
{username.length > 0 ? username : '[username]'}
. .
{magic} {magic}
</Code> </Code>
-1
View File
@@ -59,7 +59,6 @@ export default function Rename({ username, magic }: Props) {
{' '} {' '}
<Code> <Code>
[machine]. [machine].
{newName.length > 0 ? newName : '[new-username]'}
. .
{magic} {magic}
</Code> </Code>
+28 -2
View File
@@ -13,6 +13,7 @@ import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale'
import log from '~/utils/log' import log from '~/utils/log'
export interface HeadplaneContext { export interface HeadplaneContext {
debug: boolean
headscaleUrl: string headscaleUrl: string
cookieSecret: string cookieSecret: string
integration: IntegrationFactory | undefined integration: IntegrationFactory | undefined
@@ -38,6 +39,12 @@ export async function loadContext(): Promise<HeadplaneContext> {
return context return context
} }
const debug = process.env.DEBUG === 'true'
if (debug) {
log.info('CTXT', 'Debug mode is enabled! Logs will spam a lot.')
log.info('CTXT', 'Please disable debug mode in production.')
}
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml') const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
const { config, contextData } = await checkConfig(path) const { config, contextData } = await checkConfig(path)
@@ -60,6 +67,7 @@ export async function loadContext(): Promise<HeadplaneContext> {
} }
context = { context = {
debug,
headscaleUrl, headscaleUrl,
cookieSecret, cookieSecret,
integration: await loadIntegration(), integration: await loadIntegration(),
@@ -80,10 +88,13 @@ export async function loadContext(): Promise<HeadplaneContext> {
} }
async function checkConfig(path: string) { async function checkConfig(path: string) {
log.debug('CTXT', 'Checking config at %s', path)
let config: HeadscaleConfig | undefined let config: HeadscaleConfig | undefined
try { try {
config = await loadConfig(path) config = await loadConfig(path)
} catch { } catch {
log.debug('CTXT', 'Config at %s failed to load', path)
return { return {
config: undefined, config: undefined,
contextData: { contextData: {
@@ -95,9 +106,12 @@ async function checkConfig(path: string) {
let write = false let write = false
try { try {
log.debug('CTXT', 'Checking write access to %s', path)
await access(path, constants.W_OK) await access(path, constants.W_OK)
write = true write = true
} catch {} } catch {
log.debug('CTXT', 'No write access to %s', path)
}
return { return {
config, config,
@@ -109,7 +123,12 @@ async function checkConfig(path: string) {
} }
async function checkOidc(config?: HeadscaleConfig) { async function checkOidc(config?: HeadscaleConfig) {
log.debug('CTXT', 'Checking OIDC configuration')
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true' const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true'
log.debug('CTXT', 'API Key Login Enabled: %s', !disableKeyLogin)
log.debug('CTXT', 'Checking ROOT_API_KEY and falling back to API_KEY')
const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY
if (!rootKey) { if (!rootKey) {
throw new Error('ROOT_API_KEY or API_KEY not set') throw new Error('ROOT_API_KEY or API_KEY not set')
@@ -119,6 +138,10 @@ async function checkOidc(config?: HeadscaleConfig) {
let client = process.env.OIDC_CLIENT_ID let client = process.env.OIDC_CLIENT_ID
let secret = process.env.OIDC_CLIENT_SECRET let secret = process.env.OIDC_CLIENT_SECRET
log.debug('CTXT', 'Checking OIDC environment variables')
log.debug('CTXT', 'Issuer: %s', issuer)
log.debug('CTXT', 'Client: %s', client)
if ( if (
(issuer ?? client ?? secret) (issuer ?? client ?? secret)
&& !(issuer && client && secret) && !(issuer && client && secret)
@@ -143,6 +166,7 @@ async function checkOidc(config?: HeadscaleConfig) {
secret = config.oidc?.client_secret secret = config.oidc?.client_secret
if (!secret && config.oidc?.client_secret_path) { if (!secret && config.oidc?.client_secret_path) {
log.debug('CTXT', 'Trying to read OIDC client secret from %s', config.oidc.client_secret_path)
try { try {
const data = await readFile( const data = await readFile(
config.oidc.client_secret_path, config.oidc.client_secret_path,
@@ -152,7 +176,9 @@ async function checkOidc(config?: HeadscaleConfig) {
if (data && data.length > 0) { if (data && data.length > 0) {
secret = data.trim() secret = data.trim()
} }
} catch {} } catch {
log.error('CTXT', 'Failed to read OIDC client secret from %s', config.oidc.client_secret_path)
}
} }
} }
+7
View File
@@ -187,10 +187,12 @@ export async function loadConfig(path?: string) {
throw new Error('Path is required to lazy load config') throw new Error('Path is required to lazy load config')
} }
log.debug('CFGX', 'Loading Headscale configuration from %s', path)
const data = await readFile(path, 'utf8') const data = await readFile(path, 'utf8')
configYaml = parseDocument(data) configYaml = parseDocument(data)
if (process.env.HEADSCALE_CONFIG_UNSTRICT === 'true') { if (process.env.HEADSCALE_CONFIG_UNSTRICT === 'true') {
log.debug('CFGX', 'Loaded Headscale configuration in non-strict mode')
const loaded = configYaml.toJSON() as Record<string, unknown> const loaded = configYaml.toJSON() as Record<string, unknown>
config = { config = {
...loaded, ...loaded,
@@ -249,8 +251,10 @@ export async function loadConfig(path?: string) {
} }
try { try {
log.debug('CFGX', 'Attempting to parse Headscale configuration')
config = await HeadscaleConfig.parseAsync(configYaml.toJSON()) config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
} catch (error) { } catch (error) {
log.debug('CFGX', 'Failed to load Headscale configuration')
if (error instanceof z.ZodError) { if (error instanceof z.ZodError) {
log.error('CFGX', 'Recieved invalid configuration file') log.error('CFGX', 'Recieved invalid configuration file')
log.error('CFGX', 'The following schema issues were found:') log.error('CFGX', 'The following schema issues were found:')
@@ -279,7 +283,9 @@ export async function patchConfig(partial: Record<string, unknown>) {
throw new Error('Config not loaded') throw new Error('Config not loaded')
} }
log.debug('CFGX', 'Patching Headscale configuration')
for (const [key, value] of Object.entries(partial)) { for (const [key, value] of Object.entries(partial)) {
log.debug('CFGX', 'Patching %s with %s', key, value)
// If the key is something like `test.bar."foo.bar"`, then we treat // 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 // the foo.bar as a single key, and not as two keys, so that needs
// to be split correctly. // to be split correctly.
@@ -321,5 +327,6 @@ export async function patchConfig(partial: Record<string, unknown>) {
: (await HeadscaleConfig.parseAsync(configYaml.toJSON())) : (await HeadscaleConfig.parseAsync(configYaml.toJSON()))
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml') const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
log.debug('CFGX', 'Writing patched configuration to %s', path)
await writeFile(path, configYaml.toString(), 'utf8') await writeFile(path, configYaml.toString(), 'utf8')
} }
+13
View File
@@ -1,4 +1,5 @@
import { loadContext } from './config/headplane' import { loadContext } from './config/headplane'
import log from './log'
export class HeadscaleError extends Error { export class HeadscaleError extends Error {
status: number status: number
@@ -20,6 +21,8 @@ export class FatalError extends Error {
export async function pull<T>(url: string, key: string) { export async function pull<T>(url: string, key: string) {
const context = await loadContext() const context = await loadContext()
const prefix = context.headscaleUrl const prefix = context.headscaleUrl
log.debug('APIC', 'GET %s', `${prefix}/api/${url}`)
const response = await fetch(`${prefix}/api/${url}`, { const response = await fetch(`${prefix}/api/${url}`, {
headers: { headers: {
Authorization: `Bearer ${key}`, Authorization: `Bearer ${key}`,
@@ -27,6 +30,7 @@ export async function pull<T>(url: string, key: string) {
}) })
if (!response.ok) { if (!response.ok) {
log.debug('APIC', 'GET %s failed with status %d', `${prefix}/api/${url}`, response.status)
throw new HeadscaleError(await response.text(), response.status) throw new HeadscaleError(await response.text(), response.status)
} }
@@ -36,6 +40,8 @@ export async function pull<T>(url: string, key: string) {
export async function post<T>(url: string, key: string, body?: unknown) { export async function post<T>(url: string, key: string, body?: unknown) {
const context = await loadContext() const context = await loadContext()
const prefix = context.headscaleUrl const prefix = context.headscaleUrl
log.debug('APIC', 'POST %s', `${prefix}/api/${url}`)
const response = await fetch(`${prefix}/api/${url}`, { const response = await fetch(`${prefix}/api/${url}`, {
method: 'POST', method: 'POST',
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
@@ -45,6 +51,7 @@ export async function post<T>(url: string, key: string, body?: unknown) {
}) })
if (!response.ok) { if (!response.ok) {
log.debug('APIC', 'POST %s failed with status %d', `${prefix}/api/${url}`, response.status)
throw new HeadscaleError(await response.text(), response.status) throw new HeadscaleError(await response.text(), response.status)
} }
@@ -54,6 +61,8 @@ export async function post<T>(url: string, key: string, body?: unknown) {
export async function put<T>(url: string, key: string, body?: unknown) { export async function put<T>(url: string, key: string, body?: unknown) {
const context = await loadContext() const context = await loadContext()
const prefix = context.headscaleUrl const prefix = context.headscaleUrl
log.debug('APIC', 'PUT %s', `${prefix}/api/${url}`)
const response = await fetch(`${prefix}/api/${url}`, { const response = await fetch(`${prefix}/api/${url}`, {
method: 'PUT', method: 'PUT',
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
@@ -63,6 +72,7 @@ export async function put<T>(url: string, key: string, body?: unknown) {
}) })
if (!response.ok) { if (!response.ok) {
log.debug('APIC', 'PUT %s failed with status %d', `${prefix}/api/${url}`, response.status)
throw new HeadscaleError(await response.text(), response.status) throw new HeadscaleError(await response.text(), response.status)
} }
@@ -72,6 +82,8 @@ export async function put<T>(url: string, key: string, body?: unknown) {
export async function del<T>(url: string, key: string) { export async function del<T>(url: string, key: string) {
const context = await loadContext() const context = await loadContext()
const prefix = context.headscaleUrl const prefix = context.headscaleUrl
log.debug('APIC', 'DELETE %s', `${prefix}/api/${url}`)
const response = await fetch(`${prefix}/api/${url}`, { const response = await fetch(`${prefix}/api/${url}`, {
method: 'DELETE', method: 'DELETE',
headers: { headers: {
@@ -80,6 +92,7 @@ export async function del<T>(url: string, key: string) {
}) })
if (!response.ok) { if (!response.ok) {
log.debug('APIC', 'DELETE %s failed with status %d', `${prefix}/api/${url}`, response.status)
throw new HeadscaleError(await response.text(), response.status) throw new HeadscaleError(await response.text(), response.status)
} }
+6
View File
@@ -10,6 +10,12 @@ export default {
error: (category: string, message: string, ...args: unknown[]) => { error: (category: string, message: string, ...args: unknown[]) => {
defaultLog('ERRO', category, message, ...args) defaultLog('ERRO', category, message, ...args)
}, },
debug: (category: string, message: string, ...args: unknown[]) => {
if (process.env.DEBUG === 'true') {
defaultLog('DEBG', category, message, ...args)
}
}
} }
function defaultLog( function defaultLog(
+1 -1
View File
@@ -2,7 +2,7 @@ import { redirect } from '@remix-run/node'
import { import {
authorizationCodeGrantRequest, authorizationCodeGrantRequest,
calculatePKCECodeChallenge, calculatePKCECodeChallenge,
type Client, Client,
discoveryRequest, discoveryRequest,
generateRandomCodeVerifier, generateRandomCodeVerifier,
generateRandomNonce, generateRandomNonce,
+1 -1
View File
@@ -61,7 +61,7 @@ ACLs when the file is changed.
## Deployment ## Deployment
Requirements: Requirements:
- Headscale 0.23 beta-2 or later - Headscale 0.23 or newer
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc) - Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
Currently there are 3 integration providers that can do this for you: Currently there are 3 integration providers that can do this for you:
+2 -2
View File
@@ -16,7 +16,7 @@ Headplane in a production environment.
## Deployment ## Deployment
Requirements: Requirements:
- Headscale 0.23 beta-2 or later - Headscale 0.23 or newer
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc) - Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
Docker heavily simplifies the deployment process, but this process can be Docker heavily simplifies the deployment process, but this process can be
@@ -29,7 +29,7 @@ Here is a simple Docker Compose deployment:
services: services:
headplane: headplane:
container_name: headplane container_name: headplane
image: ghcr.io/tale/headplane:latest image: ghcr.io/tale/headplane:0.3.0
restart: unless-stopped restart: unless-stopped
ports: ports:
- '3000:3000' - '3000:3000'
+1
View File
@@ -9,6 +9,7 @@ You can configure Headplane using environment variables.
#### Optional Variables #### Optional Variables
- **`DEBUG`**: Enable debug logging (default: `false`).
- **`HOST`**: The host to bind the server to (default: `0.0.0.0`). - **`HOST`**: The host to bind the server to (default: `0.0.0.0`).
- **`PORT`**: The port to bind the server to (default: `3000`). - **`PORT`**: The port to bind the server to (default: `3000`).
- **`CONFIG_FILE`**: The path to the Headscale `config.yaml` (default: `/etc/headscale/config.yaml`). - **`CONFIG_FILE`**: The path to the Headscale `config.yaml` (default: `/etc/headscale/config.yaml`).
+2 -2
View File
@@ -37,7 +37,7 @@ that you'll NEED to setup a reverse proxy and this is incomplete:
```yaml ```yaml
services: services:
headscale: headscale:
image: 'headscale/headscale:0.23.0-beta2' image: 'headscale/headscale:0.23.0'
container_name: 'headscale' container_name: 'headscale'
restart: 'unless-stopped' restart: 'unless-stopped'
command: 'serve' command: 'serve'
@@ -50,7 +50,7 @@ services:
TZ: 'America/New_York' TZ: 'America/New_York'
headplane: headplane:
container_name: headplane container_name: headplane
image: ghcr.io/tale/headplane:latest image: ghcr.io/tale/headplane:0.3.0
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- './data:/var/lib/headscale' - './data:/var/lib/headscale'
+2 -2
View File
@@ -88,7 +88,7 @@ spec:
serviceAccountName: default serviceAccountName: default
containers: containers:
- name: headplane - name: headplane
image: ghcr.io/tale/headplane:latest image: ghcr.io/tale/headplane:0.3.0
env: env:
- name: COOKIE_SECRET - name: COOKIE_SECRET
value: 'abcdefghijklmnopqrstuvwxyz' value: 'abcdefghijklmnopqrstuvwxyz'
@@ -107,7 +107,7 @@ spec:
mountPath: /etc/headscale mountPath: /etc/headscale
- name: headscale - name: headscale
image: headscale/headscale:0.23.0-beta2 image: headscale/headscale:0.23.0
command: ['serve'] command: ['serve']
env: env:
- name: TZ - name: TZ
+1 -1
View File
@@ -51,7 +51,7 @@
"typescript": "^5.5.3", "typescript": "^5.5.3",
"vite": "^5.3.3", "vite": "^5.3.3",
"vite-plugin-babel": "^1.2.0", "vite-plugin-babel": "^1.2.0",
"vite-tsconfig-paths": "^4.2.1" "vite-tsconfig-paths": "^4.3.2"
}, },
"overrides": { "overrides": {
"@types/react": "npm:types-react@beta", "@types/react": "npm:types-react@beta",
+7 -12
View File
@@ -129,7 +129,7 @@ importers:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0(@babel/core@7.24.7)(vite@5.3.3(@types/node@20.14.10)) version: 1.2.0(@babel/core@7.24.7)(vite@5.3.3(@types/node@20.14.10))
vite-tsconfig-paths: vite-tsconfig-paths:
specifier: ^4.2.1 specifier: ^4.3.2
version: 4.3.2(typescript@5.5.3)(vite@5.3.3(@types/node@20.14.10)) version: 4.3.2(typescript@5.5.3)(vite@5.3.3(@types/node@20.14.10))
packages: packages:
@@ -1889,11 +1889,8 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'} engines: {node: '>= 6'}
caniuse-lite@1.0.30001600: caniuse-lite@1.0.30001666:
resolution: {integrity: sha512-+2S9/2JFhYmYaDpZvo0lKkfvuKIglrx68MwOBqMGHhQsNkLjB5xtc/TGoEPs+MxjSyN/72qer2g97nzR641mOQ==} resolution: {integrity: sha512-gD14ICmoV5ZZM1OdzPWmpx+q4GyefaK06zi8hmfHV5xe4/2nOQX3+Dw5o+fSqOws2xVwL9j+anOPFwHzdEdV4g==}
caniuse-lite@1.0.30001640:
resolution: {integrity: sha512-lA4VMpW0PSUrFnkmVuEKBUovSWKhj7puyCg8StBChgu298N1AtuF1sKWEvfDuimSEDbhlb/KqPKC3fs1HbuQUA==}
caseless@0.12.0: caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==} resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
@@ -6880,7 +6877,7 @@ snapshots:
autoprefixer@10.4.19(postcss@8.4.39): autoprefixer@10.4.19(postcss@8.4.39):
dependencies: dependencies:
browserslist: 4.23.0 browserslist: 4.23.0
caniuse-lite: 1.0.30001600 caniuse-lite: 1.0.30001666
fraction.js: 4.3.7 fraction.js: 4.3.7
normalize-range: 0.1.2 normalize-range: 0.1.2
picocolors: 1.0.0 picocolors: 1.0.0
@@ -6963,14 +6960,14 @@ snapshots:
browserslist@4.23.0: browserslist@4.23.0:
dependencies: dependencies:
caniuse-lite: 1.0.30001600 caniuse-lite: 1.0.30001666
electron-to-chromium: 1.4.716 electron-to-chromium: 1.4.716
node-releases: 2.0.14 node-releases: 2.0.14
update-browserslist-db: 1.0.13(browserslist@4.23.0) update-browserslist-db: 1.0.13(browserslist@4.23.0)
browserslist@4.23.1: browserslist@4.23.1:
dependencies: dependencies:
caniuse-lite: 1.0.30001640 caniuse-lite: 1.0.30001666
electron-to-chromium: 1.4.818 electron-to-chromium: 1.4.818
node-releases: 2.0.14 node-releases: 2.0.14
update-browserslist-db: 1.1.0(browserslist@4.23.1) update-browserslist-db: 1.1.0(browserslist@4.23.1)
@@ -7019,9 +7016,7 @@ snapshots:
camelcase-css@2.0.1: {} camelcase-css@2.0.1: {}
caniuse-lite@1.0.30001600: {} caniuse-lite@1.0.30001666: {}
caniuse-lite@1.0.30001640: {}
caseless@0.12.0: {} caseless@0.12.0: {}