Compare commits

...

32 Commits

Author SHA1 Message Date
Aarnav Tale 447a31f91e chore: v0.3.2 2024-10-11 03:09:56 -04:00
Aarnav Tale 9e21823163 chore: mention that settings is incomplete 2024-10-11 03:04:45 -04:00
Aarnav Tale aa7e2a3128 feat(TALE-35): implement pre-auth key management 2024-10-11 03:02:33 -04:00
Aarnav Tale ecef45c98a fix(TALE-35): use the correct machine registry endpoint 2024-10-10 11:12:35 -04:00
Aarnav Tale d165264876 fix: handle nullable expiry values from a db 2024-10-10 10:41:41 -04:00
Aarnav Tale dd479d4117 fix: remove older references to magic_dns via usernames 2024-10-10 10:03:43 -04:00
Akira Yamazaki 29d91785fb fix: build oidc callback url in a more proper way (#28) 2024-10-07 23:23:31 -04:00
Aarnav Tale 98d1cb1333 docs: mention 0.3.1 as the latest 2024-10-03 15:36:20 -04:00
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
Aarnav Tale 90f0bf2555 chore: v0.2.4 2024-08-24 10:35:05 -04:00
Aarnav Tale ea2ffdf0c1 feat: support removing config values via null 2024-08-24 10:33:30 -04:00
Aarnav Tale 9aedd9baad chore: use beta2 on the dev env 2024-08-24 10:19:07 -04:00
Aarnav Tale 690b52d8c6 chore(TALE-29): remove acl from integration/context 2024-08-24 10:19:07 -04:00
Aarnav Tale a72a3d6e5f chore(TALE-29): remove references to ACL_FILE 2024-08-24 10:19:06 -04:00
Akira Yamazaki c4c1fd8aab feat: make secure flag of cookie configurable (#26) 2024-08-24 10:18:38 -04:00
Aarnav Tale 9801ef453d fix(TALE-29): remove all old ACL_FILE handling
No longer required if the minimum is beta2
2024-08-23 16:12:46 -04:00
Aarnav Tale d041a62fcd chore: 0.2.3 2024-08-23 15:20:27 -04:00
Aarnav Tale 0e6b5ea6d0 chore: update docs for minimum beta requirement 2024-08-23 15:18:32 -04:00
Aarnav Tale a2054786f8 fix(TALE-31): use oidc variables first over config 2024-08-23 15:12:53 -04:00
Aarnav Tale 5a46fd0a97 feat(TALE-30): add support for new DNS configs
This is a breaking change to support 0.23-beta2
2024-08-22 16:55:05 -04:00
Aarnav Tale b8999161a2 feat(TALE-29): handle no ACL configurations being available 2024-08-04 17:30:41 -04:00
Aarnav Tale 224cbbdcaf chore: update to headscale beta for compose dev 2024-08-04 11:33:11 -04:00
Aarnav Tale 75ba3a3dc7 feat(TALE-29): support the headscale policy api changes 2024-08-04 11:32:29 -04:00
53 changed files with 1356 additions and 474 deletions
+28
View File
@@ -1,3 +1,31 @@
### 0.3.2 (October 11, 2024)
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
- Handle `null` values on machine expiry when using a database like PostgreSQL.
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
### 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)
- 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.
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
- Fixed a bug where removing Split DNS configurations would crash the UI.
### 0.2.3 (August 23, 2024)
- Change the minimum required version of Headscale to 0.23-beta2
- Support the new API policy mode for Headscale 0.23-beta1
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
- If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24))
### 0.2.2 (August 2, 2024) ### 0.2.2 (August 2, 2024)
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs. - Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
- Added a new logger utility that shows categories, levels, and timestamps. - Added a new logger utility that shows categories, levels, and timestamps.
+1 -6
View File
@@ -10,7 +10,6 @@ import TabLink from './TabLink'
interface Properties { interface Properties {
readonly data?: { readonly data?: {
acl: HeadplaneContext['acl']
config: HeadplaneContext['config'] config: HeadplaneContext['config']
user?: SessionData['user'] user?: SessionData['user']
} }
@@ -102,11 +101,7 @@ export default function Header({ data }: Properties) {
<nav className="container flex items-center gap-x-4 overflow-x-auto"> <nav className="container flex items-center gap-x-4 overflow-x-auto">
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} /> <TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} /> <TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
{data?.acl.read <TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
? (
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
)
: undefined}
{data?.config.read {data?.config.read
? ( ? (
<> <>
+14 -7
View File
@@ -1,15 +1,22 @@
import { InfoIcon } from '@primer/octicons-react' import { InfoIcon } from '@primer/octicons-react'
import clsx from 'clsx' import type { ReactNode } from 'react'
import { type ReactNode } from 'react'
export default function Notice({ children }: { readonly children: ReactNode }) { import { cn } from '~/utils/cn'
interface Props {
className?: string
children: ReactNode
}
export default function Notice({ children, className }: Props) {
return ( return (
<div className={clsx( <div className={cn(
'p-4 rounded-md w-fit flex items-center gap-3', 'p-4 rounded-md w-full flex items-center gap-3',
'bg-slate-400 dark:bg-slate-700' 'bg-ui-200 dark:bg-ui-800',
className,
)} )}
> >
<InfoIcon className='h-6 w-6 text-white'/> <InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
{children} {children}
</div> </div>
) )
+48
View File
@@ -0,0 +1,48 @@
import { PlusIcon, DashIcon } from '@primer/octicons-react'
import { Dispatch, SetStateAction } from 'react'
import {
Button,
Group,
Input,
NumberField as AriaNumberField
} from 'react-aria-components'
import { cn } from '~/utils/cn'
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
label: string;
state?: [number, Dispatch<SetStateAction<number>>];
}
export default function NumberField(props: NumberFieldProps) {
return (
<AriaNumberField
{...props}
aria-label={props.label}
className="w-full"
value={props.state?.[0]}
onChange={value => {
props.state?.[1](value)
}}
>
<Group className={cn(
'flex px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300 gap-2',
'focus-within:ring-2 focus-within:ring-blue-600',
props.className
)}>
<Input
className="w-full bg-transparent focus:outline-none"
name={props.name}
/>
<Button slot="decrement">
<DashIcon className="w-4 h-4" />
</Button>
<Button slot="increment">
<PlusIcon className="w-4 h-4" />
</Button>
</Group>
</AriaNumberField>
)
}
+18 -36
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,
) )
@@ -88,36 +93,6 @@ export default createIntegration<Context>({
return context.client !== undefined return context.client !== undefined
}, },
onAclChange: async (context) => {
if (!context.client || !context.container) {
return
}
log.info('INTG', 'Sending SIGHUP to Headscale via Docker')
let attempts = 0
while (attempts <= context.maxAttempts) {
const response = await context.client.request({
method: 'POST',
path: `/v1.30/containers/${context.container}/kill?signal=SIGHUP`,
})
if (response.statusCode !== 204) {
if (attempts < context.maxAttempts) {
attempts++
await setTimeout(1000)
continue
}
const stringCode = response.statusCode.toString()
const body = await response.body.text()
throw new Error(`API request failed: ${stringCode} ${body}`)
}
break
}
},
onConfigChange: async (context) => { onConfigChange: async (context) => {
if (!context.client || !context.container) { if (!context.client || !context.container) {
return return
@@ -127,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`,
@@ -150,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) {
-1
View File
@@ -3,7 +3,6 @@ export interface IntegrationFactory<T = any> {
name: string name: string
context: T context: T
isAvailable: (context: T) => Promise<boolean> | boolean isAvailable: (context: T) => Promise<boolean> | boolean
onAclChange?: (context: T) => Promise<void> | void
onConfigChange?: (context: T) => Promise<void> | void onConfigChange?: (context: T) => Promise<void> | void
} }
+17 -10
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,
@@ -177,15 +193,6 @@ export default createIntegration<Context>({
} }
}, },
onAclChange: (context) => {
if (!context.pid) {
return
}
log.info('INTG', 'Sending SIGHUP to Headscale')
kill(context.pid, 'SIGHUP')
},
onConfigChange: (context) => { onConfigChange: (context) => {
if (!context.pid) { if (!context.pid) {
return return
+7 -11
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,
@@ -70,14 +75,5 @@ export default createIntegration<Context>({
log.error('INTG', 'Failed to read /proc') log.error('INTG', 'Failed to read /proc')
return false return false
} }
}, }
onAclChange: (context) => {
if (!context.pid) {
return
}
log.info('INTG', 'Sending SIGHUP to Headscale')
kill(context.pid, 'SIGHUP')
},
}) })
+17 -15
View File
@@ -5,12 +5,12 @@ import { ClientOnly } from 'remix-utils/client-only'
import Fallback from '~/routes/_data.acls._index/fallback' import Fallback from '~/routes/_data.acls._index/fallback'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
interface MonacoProps { interface Props {
variant: 'editor' | 'diff' variant: 'edit' | 'diff'
language: 'json' | 'yaml' language: 'json' | 'yaml'
value: string state: [string, (value: string) => void]
onChange: (value: string) => void policy?: string
original?: string isDisabled?: boolean
} }
function monacoCallback(monaco: Monaco) { function monacoCallback(monaco: Monaco) {
@@ -26,7 +26,7 @@ function monacoCallback(monaco: Monaco) {
monaco.languages.register({ id: 'yaml' }) monaco.languages.register({ id: 'yaml' })
} }
export default function MonacoEditor({ value, onChange, variant, original, language }: MonacoProps) { export default function MonacoEditor({ variant, language, state, policy, isDisabled }: Props) {
const [light, setLight] = useState(false) const [light, setLight] = useState(false)
useEffect(() => { useEffect(() => {
@@ -46,29 +46,30 @@ export default function MonacoEditor({ value, onChange, variant, original, langu
)} )}
> >
<div className="overflow-y-scroll h-editor text-sm"> <div className="overflow-y-scroll h-editor text-sm">
<ClientOnly fallback={<Fallback acl={value} />}> <ClientOnly fallback={<Fallback acl={state[0]} />}>
{() => variant === 'editor' {() => variant === 'edit'
? ( ? (
<Editor <Editor
height="100%" height="100%"
language={language} language={language}
theme={light ? 'light' : 'vs-dark'} theme={light ? 'light' : 'vs-dark'}
value={value} value={state[0]}
onChange={(updated) => { onChange={(updated) => {
if (!updated) { if (!updated) {
return return
} }
if (updated !== value) { if (updated !== state[0]) {
onChange(updated) state[1](updated)
} }
}} }}
loading={<Fallback acl={value} />} loading={<Fallback acl={state[0]} />}
beforeMount={monacoCallback} beforeMount={monacoCallback}
options={{ options={{
wordWrap: 'on', wordWrap: 'on',
minimap: { enabled: false }, minimap: { enabled: false },
fontSize: 14, fontSize: 14,
readOnly: isDisabled,
}} }}
/> />
) )
@@ -77,14 +78,15 @@ export default function MonacoEditor({ value, onChange, variant, original, langu
height="100%" height="100%"
language={language} language={language}
theme={light ? 'light' : 'vs-dark'} theme={light ? 'light' : 'vs-dark'}
original={original} original={policy}
modified={value} modified={state[0]}
loading={<Fallback acl={value} />} loading={<Fallback acl={state[0]} />}
beforeMount={monacoCallback} beforeMount={monacoCallback}
options={{ options={{
wordWrap: 'on', wordWrap: 'on',
minimap: { enabled: false }, minimap: { enabled: false },
fontSize: 13, fontSize: 13,
readOnly: isDisabled,
}} }}
/> />
)} )}
+148 -36
View File
@@ -1,32 +1,65 @@
/* 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 } 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 { 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 Link from '~/components/Link' import Link from '~/components/Link'
import Notice from '~/components/Notice' import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner' import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster' import { toast } from '~/components/Toaster'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane'
import { HeadscaleError, pull, put } from '~/utils/headscale'
import { getSession } from '~/utils/sessions' import { getSession } from '~/utils/sessions'
import Monaco from './editor' import Monaco from './editor'
export async function loader() { export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext() const session = await getSession(request.headers.get('Cookie'))
if (!context.acl.read) {
throw new Error('No ACL configuration is available') try {
} const { policy } = await pull<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
)
try {
// We have read access, now do we have write access?
// Attempt to set the policy to what we just got
await put('v1/policy', session.get('hsApiKey')!, {
policy,
})
return {
hasAclWrite: true,
currentAcl: policy,
aclType: 'json',
} as const
} catch (error) {
if (!(error instanceof HeadscaleError)) {
throw error
}
if (error.status === 500) {
return {
hasAclWrite: false,
currentAcl: policy,
aclType: 'json',
} as const
}
}
} catch {}
const { data, type } = await loadAcl()
return { return {
hasAclWrite: context.acl.write, hasAclWrite: true,
currentAcl: data, currentAcl: '',
aclType: type, aclType: 'json',
} } as const
} }
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
@@ -37,27 +70,102 @@ export async function action({ request }: ActionFunctionArgs) {
}) })
} }
const context = await loadContext() const { acl } = await request.json() as { acl: string, api: boolean }
if (!context.acl.write) { try {
return json({ success: false }, { await put('v1/policy', session.get('hsApiKey')!, {
status: 403, policy: acl,
}) })
}
const data = await request.json() as { acl: string } await setTimeout(250)
await patchAcl(data.acl) return json({ success: true })
} catch (error) {
if (context.integration?.onAclChange) { return json({ success: false }, {
await context.integration.onAclChange(context.integration.context) status: error instanceof HeadscaleError ? error.status : 500,
})
} }
return json({ success: true }) return json({ success: true })
} }
export function ErrorBoundary() {
return (
<div>
<Notice className="mb-4">
An ACL policy is not available or an error occurred while trying to fetch it.
</Notice>
<h1 className="text-2xl font-medium mb-4">
Access Control List (ACL)
</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your network.
You can find more information about the ACL file in the
{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>
{' '}
and the
{' '}
<Link
to="https://headscale.net/acls"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
<div>
<div className="max-w-prose">
<p className="mb-4 text-md">
If you are running Headscale 0.23-beta1 or later, the
ACL configuration is most likely set to
{' '}
<Code>file</Code>
{' '}
mode but the ACL file is not available. In order to
resolve this you will either need to correctly set
{' '}
<Code>policy.path</Code>
{' '}
in your Headscale configuration or set the
{' '}
<Code>policy.mode</Code>
{' '}
to
{' '}
<Code>database</Code>
.
</p>
</div>
</div>
</div>
)
}
export default function Page() { export default function Page() {
const data = useLoaderData<typeof loader>() const data = useLoaderData<typeof loader>()
const fetcher = useFetcher<typeof action>()
const [acl, setAcl] = useState(data.currentAcl) const [acl, setAcl] = useState(data.currentAcl)
const fetcher = useFetcher() const [toasted, setToasted] = useState(false)
useEffect(() => {
if (!fetcher.data || toasted) {
return
}
if (fetcher.data.success) {
toast('Updated tailnet ACL policy')
} else {
toast('Failed to update tailnet ACL policy')
}
setToasted(true)
setAcl(data.currentAcl)
}, [fetcher.data, toasted, data.currentAcl])
return ( return (
<div> <div>
@@ -65,9 +173,12 @@ export default function Page() {
? undefined ? undefined
: ( : (
<div className="mb-4"> <div className="mb-4">
<Notice> <Notice className="w-fit">
The ACL policy file is readonly to Headplane. The ACL policy is read-only. You can view the current policy
You will not be able to make changes here. but you cannot make changes to it.
<br />
To resolve this, you need to set the ACL policy mode to
database in your Headscale configuration.
</Notice> </Notice>
</div> </div>
)} )}
@@ -144,19 +255,18 @@ export default function Page() {
</TabList> </TabList>
<TabPanel id="edit"> <TabPanel id="edit">
<Monaco <Monaco
variant="editor" isDisabled={!data.hasAclWrite}
variant="edit"
language={data.aclType} language={data.aclType}
value={acl} state={[acl, setAcl]}
onChange={setAcl}
/> />
</TabPanel> </TabPanel>
<TabPanel id="diff"> <TabPanel id="diff">
<Monaco <Monaco
variant="diff" variant="diff"
language={data.aclType} language={data.aclType}
value={acl} state={[acl, setAcl]}
onChange={setAcl} policy={data.currentAcl}
original={data.currentAcl}
/> />
</TabPanel> </TabPanel>
<TabPanel id="preview"> <TabPanel id="preview">
@@ -180,14 +290,13 @@ export default function Page() {
className="mr-2" className="mr-2"
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl} isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
onPress={() => { onPress={() => {
setToasted(false)
fetcher.submit({ fetcher.submit({
acl, acl,
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json', encType: 'application/json',
}) })
toast('Updated tailnet ACL policy')
}} }}
> >
{fetcher.state === 'idle' {fetcher.state === 'idle'
@@ -197,7 +306,10 @@ export default function Page() {
)} )}
Save Save
</Button> </Button>
<Button onPress={() => { setAcl(data.currentAcl) }}> <Button
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl || !data.hasAclWrite}
onPress={() => { setAcl(data.currentAcl) }}
>
Discard Changes Discard Changes
</Button> </Button>
</div> </div>
+1 -1
View File
@@ -47,7 +47,7 @@ export default function AddDNS({ records }: Props) {
setIp('') setIp('')
submit({ submit({
'dns_config.extra_records': [ 'dns.extra_records': [
...records, ...records,
{ {
name, name,
@@ -55,7 +55,7 @@ export default function AddNameserver({ nameservers }: Props) {
} }
submit({ submit({
'dns_config.restricted_nameservers': splitNs, 'dns.nameservers.split': splitNs,
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json', encType: 'application/json',
@@ -65,7 +65,7 @@ export default function AddNameserver({ nameservers }: Props) {
globalNs.push(ns) globalNs.push(ns)
submit({ submit({
'dns_config.nameservers': globalNs, 'dns.nameservers.global': globalNs,
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json', encType: 'application/json',
+1 -1
View File
@@ -63,7 +63,7 @@ export default function DNS({ records, isDisabled }: Props) {
isDisabled={isDisabled} isDisabled={isDisabled}
onPress={() => { onPress={() => {
submit({ submit({
'dns_config.extra_records': records 'dns.extra_records': records
.filter((_, i) => i !== index), .filter((_, i) => i !== index),
}, { }, {
method: 'PATCH', method: 'PATCH',
+2 -3
View File
@@ -134,7 +134,7 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
onPress={() => { onPress={() => {
fetcher.submit({ fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': [...localDomains, newDomain] 'dns.search_domains': [...localDomains, newDomain]
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json' encType: 'application/json'
@@ -212,8 +212,7 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
isDisabled={disabled} isDisabled={disabled}
onPress={() => { onPress={() => {
fetcher.submit({ fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention 'dns.search_domains': localDomains.filter((_, index) => index !== id - 1)
'dns_config.domains': localDomains.filter((_, index) => index !== id - 1)
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json' encType: 'application/json'
+1 -1
View File
@@ -42,7 +42,7 @@ export default function Modal({ isEnabled, disabled }: Properties) {
onPress={() => { onPress={() => {
fetcher.submit({ fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention // eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.magic_dns': !isEnabled 'dns.magic_dns': !isEnabled
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json' encType: 'application/json'
+6 -34
View File
@@ -11,11 +11,10 @@ import AddNameserver from './dialogs/nameserver'
interface Props { interface Props {
nameservers: Record<string, string[]> nameservers: Record<string, string[]>
override: boolean
isDisabled: boolean isDisabled: boolean
} }
export default function Nameservers({ nameservers, override, isDisabled }: Props) { export default function Nameservers({ nameservers, isDisabled }: Props) {
return ( return (
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1> <h1 className="text-2xl font-medium mb-4">Nameservers</h1>
@@ -37,7 +36,6 @@ export default function Nameservers({ nameservers, override, isDisabled }: Props
isGlobal={key === 'global'} isGlobal={key === 'global'}
isDisabled={isDisabled} isDisabled={isDisabled}
nameservers={nameservers[key]} nameservers={nameservers[key]}
override={override}
name={key} name={key}
/> />
))} ))}
@@ -57,11 +55,9 @@ interface ListProps {
isDisabled: boolean isDisabled: boolean
nameservers: string[] nameservers: string[]
name: string name: string
override: boolean
} }
function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: ListProps) { function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
const [localOverride, setLocalOverride] = useState(override)
const submit = useSubmit() const submit = useSubmit()
return ( return (
@@ -70,30 +66,6 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: L
<h2 className="text-md font-medium opacity-80"> <h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name} {isGlobal ? 'Global Nameservers' : name}
</h2> </h2>
{isGlobal
? (
<div className="flex gap-2 items-center">
<span className="text-sm opacity-50">
Override local DNS
</span>
<Switch
label="Override local DNS"
defaultSelected={localOverride}
isDisabled={isDisabled}
onChange={() => {
submit({
'dns_config.override_local_dns': !localOverride,
}, {
method: 'PATCH',
encType: 'application/json',
})
setLocalOverride(!localOverride)
}}
/>
</div>
)
: undefined}
</div> </div>
<TableList> <TableList>
{nameservers.map((ns, index) => ( {nameservers.map((ns, index) => (
@@ -111,17 +83,17 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: L
onPress={() => { onPress={() => {
if (isGlobal) { if (isGlobal) {
submit({ submit({
'dns_config.nameservers': nameservers 'dns.nameservers.global': nameservers
.filter((_, i) => i !== index), .filter((_, i) => i !== index),
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json', encType: 'application/json',
}) })
} else { } else {
const key = `dns_config.restricted_nameservers."${name}"` const key = `dns.nameservers.split."${name}"`
const list = nameservers.filter((_, i) => i !== index)
submit({ submit({
[key]: nameservers [key]: list.length ? list : null,
.filter((_, i) => i !== index),
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json', encType: 'application/json',
+2 -2
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.
@@ -80,7 +80,7 @@ export default function Modal({ name, disabled }: Properties) {
variant='confirm' variant='confirm'
onPress={() => { onPress={() => {
fetcher.submit({ fetcher.submit({
'dns_config.base_domain': newName 'dns.base_domain': newName
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json' encType: 'application/json'
+9 -9
View File
@@ -24,13 +24,14 @@ export async function loader() {
const config = await loadConfig() const config = await loadConfig()
const dns = { const dns = {
prefixes: config.prefixes, prefixes: config.prefixes,
magicDns: config.dns_config.magic_dns, magicDns: config.dns.magic_dns,
baseDomain: config.dns_config.base_domain, baseDomain: config.dns.use_username_in_magic_dns
overrideLocal: config.dns_config.override_local_dns, ? `[user].${config.dns.base_domain}`
nameservers: config.dns_config.nameservers, : config.dns.base_domain,
splitDns: config.dns_config.restricted_nameservers, nameservers: config.dns.nameservers.global,
searchDomains: config.dns_config.domains, splitDns: config.dns.nameservers.split,
extraRecords: config.dns_config.extra_records, searchDomains: config.dns.search_domains,
extraRecords: config.dns.extra_records,
} }
return { return {
@@ -87,7 +88,6 @@ export default function Page() {
<RenameModal name={data.baseDomain} disabled={!data.config.write} /> <RenameModal name={data.baseDomain} disabled={!data.config.write} />
<Nameservers <Nameservers
nameservers={allNs} nameservers={allNs}
override={data.overrideLocal}
isDisabled={!data.config.write} isDisabled={!data.config.write}
/> />
@@ -109,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>
{' '} {' '}
+10 -6
View File
@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */ /* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node' import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { Link, useLoaderData } from '@remix-run/react' import { Link, useLoaderData } from '@remix-run/react'
import Attribute from '~/components/Attribute' import Attribute from '~/components/Attribute'
import Card from '~/components/Card' import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle' import StatusCircle from '~/components/StatusCircle'
import { type Machine, Route, User } from '~/types' import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane' import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale' import { loadConfig } from '~/utils/config/headscale'
@@ -27,8 +27,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
if (context.config.read) { if (context.config.read) {
const config = await loadConfig() const config = await loadConfig()
if (config.dns_config.magic_dns) { if (config.dns.magic_dns) {
magic = config.dns_config.base_domain magic = config.dns.base_domain
} }
} }
@@ -56,6 +56,7 @@ export default function Page() {
const expired = machine.expiry === '0001-01-01 00:00:00' const expired = machine.expiry === '0001-01-01 00:00:00'
|| machine.expiry === '0001-01-01T00:00:00Z' || machine.expiry === '0001-01-01T00:00:00Z'
|| machine.expiry === null
? false ? false
: new Date(machine.expiry).getTime() < Date.now() : new Date(machine.expiry).getTime() < Date.now()
@@ -134,14 +135,17 @@ export default function Page() {
/> />
<Attribute <Attribute
name="Expiry" name="Expiry"
value={new Date(machine.expiry).toLocaleString()} value={expired
? new Date(machine.expiry).toLocaleString()
: 'Never'
}
/> />
{magic {magic
? ( ? (
<Attribute <Attribute
isCopyable isCopyable
name="Domain" name="Domain"
value={`${machine.givenName}.${machine.user.name}.${magic}`} value={`${machine.givenName}.${magic}`}
/> />
) )
: undefined} : undefined}
@@ -97,6 +97,46 @@ 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 {
const qp = new URLSearchParams()
qp.append('user', user)
qp.append('key', key)
const url = `v1/node/register?${qp.toString()}`
await post(url, session.get('hsApiKey')!, {
user, key,
})
return json({
success: true,
message: 'Machine registered'
})
} catch {
return json({
success: false,
message: 'Failed to register machine'
}, {
status: 500,
})
}
}
default: { default: {
return json({ message: 'Invalid method' }, { return json({ message: 'Invalid method' }, {
status: 400, status: 400,
@@ -29,8 +29,6 @@ export default function Move({ machine, state, magic, users }: MoveProps) {
</Dialog.Title> </Dialog.Title>
<Dialog.Text> <Dialog.Text>
The owner of the machine is the user associated with it. The owner of the machine is the user associated with it.
When MagicDNS is enabled, the username of the owner
will control the hostname of the machine.
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
@@ -54,46 +52,16 @@ export default function Move({ machine, state, magic, users }: MoveProps) {
</Select> </Select>
{magic {magic
? ( ? (
owner === machine.user.name <p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
? ( This machine is accessible by the hostname
<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 className="text-sm"> .
{machine.givenName} {magic}
. </Code>
{owner} .
. </p>
{magic}
</Code>
.
</p>
)
: (
<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">
{machine.givenName}
.
{owner}
.
{magic}
</Code>
{'. '}
The hostname
{' '}
<Code className="text-sm">
{machine.givenName}
.
{machine.user.name}
.
{magic}
</Code>
{' '}
will no longer point to this machine.
</p>
)
) )
: undefined} : undefined}
<div className="mt-6 flex justify-end gap-2 mt-6"> <div className="mt-6 flex justify-end gap-2 mt-6">
@@ -0,0 +1,136 @@
import { Form, useFetcher, Link } from '@remix-run/react'
import { Dispatch, SetStateAction, useState, useEffect } 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 Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import { Machine, User } from '~/types'
export interface NewProps {
server: string
users: User[]
}
export default function New(data: NewProps) {
const fetcher = useFetcher()
const mkeyState = useState(false)
const [mkey, setMkey] = useState('')
const [user, setUser] = useState('')
const [toasted, setToasted] = useState(false)
useEffect(() => {
if (!fetcher.data || toasted) {
return
}
if (fetcher.data.success) {
toast('Registered new machine')
} else {
toast('Failed to register machine due to an invalid key')
}
setToasted(true)
}, [fetcher.data, toasted, mkey])
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>
<fetcher.Form method="POST" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<TextField
label='Machine Key'
placeholder='mkey: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"
isDisabled={!mkey || !mkey.trim().startsWith('mkey:') || !user}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Register
</Dialog.Action>
</div>
</fetcher.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>
<Link to="/settings/auth-keys">
<KeyIcon className='w-4 h-4 mr-2'/>
Generate Pre-auth Key
</Link>
</Menu.ItemButton>
</Menu.Items>
</Menu>
</>
)
}
+8 -5
View File
@@ -4,7 +4,7 @@ import { Link } from '@remix-run/react'
import Menu from '~/components/Menu' import Menu from '~/components/Menu'
import StatusCircle from '~/components/StatusCircle' import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster' import { toast } from '~/components/Toaster'
import { type Machine, type Route, User } from '~/types' import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn' import { cn } from '~/utils/cn'
import MenuOptions from './menu' import MenuOptions from './menu'
@@ -19,6 +19,7 @@ interface Props {
export default function MachineRow({ machine, routes, magic, users }: Props) { export default function MachineRow({ machine, routes, magic, users }: Props) {
const expired = machine.expiry === '0001-01-01 00:00:00' const expired = machine.expiry === '0001-01-01 00:00:00'
|| machine.expiry === '0001-01-01T00:00:00Z' || machine.expiry === '0001-01-01T00:00:00Z'
|| machine.expiry === null
? false ? false
: new Date(machine.expiry).getTime() < Date.now() : new Date(machine.expiry).getTime() < Date.now()
@@ -31,6 +32,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 +107,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>
) )
@@ -29,6 +29,7 @@ export default function Menu({ machine, routes, magic, users }: MenuProps) {
const expired = machine.expiry === '0001-01-01 00:00:00' const expired = machine.expiry === '0001-01-01 00:00:00'
|| machine.expiry === '0001-01-01T00:00:00Z' || machine.expiry === '0001-01-01T00:00:00Z'
|| machine.expiry === null
? false ? false
: new Date(machine.expiry).getTime() < Date.now() : new Date(machine.expiry).getTime() < Date.now()
+13 -4
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'))
@@ -29,8 +30,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
if (context.config.read) { if (context.config.read) {
const config = await loadConfig() const config = await loadConfig()
if (config.dns_config.magic_dns) { if (config.dns.magic_dns) {
magic = config.dns_config.base_domain magic = config.dns.base_domain
}
if (config.dns.use_username_in_magic_dns) {
magic = `[user].${magic}`
} }
} }
@@ -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>
-13
View File
@@ -1,13 +0,0 @@
import { IssueDraftIcon } from '@primer/octicons-react'
export default function Page() {
return (
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center my-8'>
<IssueDraftIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
<p className='text-lg mt-8'>
The settings page is currently unavailable.
It will be available in a future release.
</p>
</div>
)
}
@@ -0,0 +1,48 @@
import Link from '~/components/Link'
import Button from '~/components/Button'
import { Link as RemixLink } from '@remix-run/react'
import { ArrowRightIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
export default function Page() {
return (
<div className="flex flex-col gap-8 max-w-screen-lg">
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Settings</h1>
<p className="text-gray-700 dark:text-gray-300">
The settings page is still under construction.
As I'm able to add more features, I'll be adding them here.
If you require any features, feel free to open an issue on
the GitHub repository.
</p>
</div>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300">
Headscale fully supports pre-authentication keys in order to
easily add devices to your Tailnet.
To learn more about using pre-authentication keys, visit the
{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
>
Tailscale documentation
</Link>
</p>
</div>
<RemixLink
to="/settings/auth-keys"
name="Auth Keys"
>
<span className={cn(
'text-lg font-medium',
'text-gray-700 dark:text-gray-300',
)}>
Manage Auth Keys
<ArrowRightIcon className="w-5 h-5 ml-2" />
</span>
</RemixLink>
</div>
)
}
@@ -0,0 +1,69 @@
import { useFetcher } from '@remix-run/react'
import type { PreAuthKey } from '~/types'
import { cn } from '~/utils/cn'
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
interface Props {
authKey: PreAuthKey
}
export default function ExpireKey({ authKey }: Props) {
const fetcher = useFetcher()
return (
<Dialog>
<Dialog.Button className="my-4">
Expire Key
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Expire auth key?
</Dialog.Title>
<fetcher.Form method="DELETE" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<input type="hidden" name="user" value={authKey.user} />
<input type="hidden" name="key" value={authKey.key} />
<Dialog.Text>
Expiring this authentication key will immediately
prevent it from being used to authenticate new devices.
{' '}
This action cannot be undone.
</Dialog.Text>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
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',
)}
onPress={close}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Expire
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -0,0 +1,151 @@
import { RepoForkedIcon } from '@primer/octicons-react'
import { useFetcher } from '@remix-run/react'
import { useState } from 'react'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import NumberField from '~/components/NumberField'
import Tooltip from '~/components/Tooltip'
import Select from '~/components/Select'
import Switch from '~/components/Switch'
import Link from '~/components/Link'
import Spinner from '~/components/Spinner'
import { cn } from '~/utils/cn'
import { User } from '~/types'
interface Props {
users: User[]
}
// TODO: Tags
export default function AddPreAuthKey(data: Props) {
const fetcher = useFetcher()
const [user, setUser] = useState('')
const [reusable, setReusable] = useState(false)
const [ephemeral, setEphemeral] = useState(false)
const [aclTags, setAclTags] = useState([])
const [expiry, setExpiry] = useState(90)
return (
<Dialog>
<Dialog.Button className="my-4">
Create pre-auth key
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Generate auth key
</Dialog.Title>
<fetcher.Form method="POST" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<Dialog.Text className="font-semibold">
User
</Dialog.Text>
<Dialog.Text className="text-sm">
Attach this key to a user
</Dialog.Text>
<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>
<Dialog.Text className="font-semibold mt-4">
Key Expiration
</Dialog.Text>
<Dialog.Text className="text-sm">
Set this key to expire between 1 and 90 days.
</Dialog.Text>
<NumberField
label="Expiry"
name="expiry"
minValue={1}
maxValue={90}
state={[expiry, setExpiry]}
formatOptions={{
style: 'unit',
unit: 'day',
unitDisplay: 'short',
}}
/>
<div className="flex justify-between items-center mt-6">
<div>
<Dialog.Text className="font-semibold">
Reusable
</Dialog.Text>
<Dialog.Text className="text-sm">
Use this key to authenticate more than one device.
</Dialog.Text>
</div>
<Switch
label="Reusable"
name="reusable"
defaultSelected={reusable}
onChange={() => { setReusable(!reusable) }}
/>
</div>
<input type="hidden" name="reusable" value={reusable} />
<div className="flex justify-between items-center mt-6">
<div>
<Dialog.Text className="font-semibold">
Ephemeral
</Dialog.Text>
<Dialog.Text className="text-sm">
Devices authenticated with this key will
be automatically removed once they go offline.
{' '}
<Link
to="https://tailscale.com/kb/1111/ephemeral-nodes"
name="Tailscale Ephemeral Nodes Documentation"
>
Learn more
</Link>
</Dialog.Text>
</div>
<Switch
label="Ephemeral"
name="ephemeral"
defaultSelected={ephemeral}
onChange={() => {
setEphemeral(!ephemeral)
}}
/>
</div>
<input type="hidden" name="ephemeral" value={ephemeral} />
<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}
isDisabled={!user || !expiry}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Generate
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -0,0 +1,55 @@
import type { PreAuthKey } from '~/types'
import { toast } from '~/components/Toaster'
import Code from '~/components/Code'
import Button from '~/components/Button'
import Attribute from '~/components/Attribute'
import ExpireKey from './dialogs/expire'
interface Props {
authKey: PreAuthKey
server: string
}
export default function AuthKeyRow({ authKey, server }: Props) {
const createdAt = new Date(authKey.createdAt).toLocaleString()
const expiration = new Date(authKey.expiration).toLocaleString()
return (
<div className="w-full">
<Attribute name="Key" value={authKey.key} isCopyable />
<Attribute name="User" value={authKey.user} isCopyable />
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
<Attribute name="Created" value={createdAt} />
<Attribute name="Expiration" value={expiration} />
<p className="mb-1 mt-4">
To use this key, run the following command on your device:
</p>
<Code className="text-sm">
tailscale up --login-server {server} --authkey {authKey.key}
</Code>
<div className="flex gap-4 items-center">
{authKey.used || new Date(authKey.expiration) < new Date()
? undefined
: (
<ExpireKey authKey={authKey} />
)}
<Button
variant="light"
className="my-4"
onClick={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server ${server} --authkey ${authKey.key}`
)
toast('Copied command to clipboard')
}}
>
Copy Tailscale Command
</Button>
</div>
</div>
)
}
@@ -0,0 +1,222 @@
import { LoaderFunctionArgs, ActionFunctionArgs, json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { useLiveData } from '~/utils/useLiveData'
import { getSession } from '~/utils/sessions'
import { Link as RemixLink } from '@remix-run/react'
import { PreAuthKey, User } from '~/types'
import { pull, post } from '~/utils/headscale'
import { loadContext } from '~/utils/config/headplane'
import { useState } from 'react'
import Link from '~/components/Link'
import TableList from '~/components/TableList'
import Select from '~/components/Select'
import Switch from '~/components/Switch'
import AddPreAuthKey from './dialogs/new'
import AuthKeyRow from './key'
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401,
})
}
const data = await request.formData()
// Expiring a pre-auth key
if (request.method === 'DELETE') {
const key = data.get('key')
const user = data.get('user')
if (!key || !user) {
return json({ message: 'Missing parameters' }, {
status: 400,
})
}
await post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey/expire',
session.get('hsApiKey')!,
{
user: user,
key: key,
}
)
return json({ message: 'Pre-auth key expired' })
}
// Creating a new pre-auth key
if (request.method === 'POST') {
const user = data.get('user')
const expiry = data.get('expiry')
const reusable = data.get('reusable')
const ephemeral = data.get('ephemeral')
if (!user || !expiry || !reusable || !ephemeral) {
return json({ message: 'Missing parameters' }, {
status: 400,
})
}
// Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(' ')[0])
const date = new Date()
date.setDate(date.getDate() + day)
const key = await post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.get('hsApiKey')!,
{
user: user,
ephemeral: ephemeral === 'on',
reusable: reusable === 'on',
expiration: date.toISOString(),
aclTags: [], // TODO
}
)
return json({ message: 'Pre-auth key created', key })
}
}
export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext()
const session = await getSession(request.headers.get('Cookie'))
const users = await pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!)
const preAuthKeys = await Promise.all(users.users.map(user => {
const qp = new URLSearchParams()
qp.set('user', user.name)
return pull<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('hsApiKey')!
)
}))
return {
keys: preAuthKeys.flatMap(keys => keys.preAuthKeys),
users: users.users,
server: context.headscaleUrl,
}
}
export default function Page() {
const { keys, users, server } = useLoaderData<typeof loader>()
const [user, setUser] = useState('All')
const [status, setStatus] = useState('Active')
const filteredKeys = keys.filter(key => {
if (user !== 'All' && key.user !== user) {
return false
}
if (status !== 'All') {
const now = new Date()
const expiry = new Date(key.expiration)
if (status === 'Active') {
return !(expiry < now) && !key.used
}
if (status === 'Used/Expired') {
return key.used || expiry < now
}
if (status === 'Reusable') {
return key.reusable
}
if (status === 'Ephemeral') {
return key.ephemeral
}
}
return true
})
return (
<div className='flex flex-col w-2/3'>
<p className="mb-8 text-md">
<RemixLink
to="/settings"
className="font-medium"
>
Settings
</RemixLink>
<span className="mx-2">
/
</span>
{' '}
Pre-Auth Keys
</p>
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300 mb-4">
Headscale fully supports pre-authentication keys in order to
easily add devices to your Tailnet.
To learn more about using pre-authentication keys, visit the
{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
>
Tailscale documentation
</Link>
</p>
<AddPreAuthKey users={users} />
<div className="flex justify-between gap-4 mt-4">
<div className="w-full">
<p className="text-sm text-gray-500 dark:text-gray-300">
Filter by user
</p>
<Select
label="Filter by User"
placeholder="Select a user"
state={[user, setUser]}
>
<Select.Item id="All">All</Select.Item>
{users.map(user => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
</div>
<div className="w-full">
<p className="text-sm text-gray-500 dark:text-gray-300">
Filter by status
</p>
<Select
label="Filter by status"
placeholder="Select a status"
state={[status, setStatus]}
>
<Select.Item id="All">All</Select.Item>
<Select.Item id="Active">Active</Select.Item>
<Select.Item id="Used/Expired">Used/Expired</Select.Item>
<Select.Item id="Reusable">Reusable</Select.Item>
<Select.Item id="Ephemeral">Ephemeral</Select.Item>
</Select>
</div>
</div>
<TableList className="mt-4">
{filteredKeys.length === 0 ? (
<TableList.Item>
<p className="opacity-50 text-sm mx-auto">
No pre-auth keys
</p>
</TableList.Item>
) : filteredKeys.map(key => (
<TableList.Item key={key.id}>
<AuthKeyRow authKey={key} server={server} />
</TableList.Item>
))}
</TableList>
</div>
)
}
-1
View File
@@ -35,7 +35,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext() const context = await loadContext()
return { return {
acl: context.acl,
config: context.config, config: context.config,
user: session.get('user'), user: session.get('user'),
} }
-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 -20
View File
@@ -8,10 +8,9 @@ import Dialog from '~/components/Dialog'
interface Props { interface Props {
username: string username: string
magic?: string
} }
export default function Remove({ username, magic }: Props) { export default function Remove({ username }: Props) {
const submit = useSubmit() const submit = useSubmit()
const dialogState = useState(false) const dialogState = useState(false)
@@ -41,24 +40,6 @@ export default function Remove({ username, magic }: Props) {
? ?
{' '} {' '}
A deleted user cannot be recovered. A deleted user cannot be recovered.
{magic
? (
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
{' '}
Since Magic DNS is enabled, machines
currently accessible via
{' '}
<Code>
[machine].
{username}
.
{magic}
</Code>
{' '}
will become orphaned and inaccessible.
</p>
)
: undefined}
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
-28
View File
@@ -3,7 +3,6 @@ import { Form, useSubmit } from '@remix-run/react'
import { useState } from 'react' import { useState } from 'react'
import Button from '~/components/Button' import Button from '~/components/Button'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog' import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField' import TextField from '~/components/TextField'
@@ -40,33 +39,6 @@ export default function Rename({ username, magic }: Props) {
Enter a new username for Enter a new username for
{' '} {' '}
{username} {username}
?
{magic
? (
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
{' '}
Since Magic DNS is enabled, machines
currently accessible via
{' '}
<Code>
[machine].
{username}
.
{magic}
</Code>
{' '}
will now become accessible via
{' '}
<Code>
[machine].
{newName.length > 0 ? newName : '[new-username]'}
.
{magic}
</Code>
.
</p>
)
: undefined}
</Dialog.Text> </Dialog.Text>
<Form <Form
method="POST" method="POST"
+3 -3
View File
@@ -41,8 +41,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
if (context.config.read) { if (context.config.read) {
const config = await loadConfig() const config = await loadConfig()
if (config.dns_config.magic_dns) { if (config.dns.magic_dns) {
magic = config.dns_config.base_domain magic = config.dns.base_domain
} }
} }
@@ -335,7 +335,7 @@ function UserCard({ user, magic }: CardProps) {
<Rename username={user.name} magic={magic} /> <Rename username={user.name} magic={magic} />
{user.machines.length === 0 {user.machines.length === 0
? ( ? (
<Remove username={user.name} magic={magic} /> <Remove username={user.name} />
) )
: undefined} : undefined}
</div> </div>
+11
View File
@@ -0,0 +1,11 @@
export interface PreAuthKey {
id: string
key: string
user: string
reusable: boolean
ephemeral: boolean
used: boolean
expiration: string
createdAt: string
aclTags: string[]
}
+1
View File
@@ -2,3 +2,4 @@ export * from './Key'
export * from './Machine' export * from './Machine'
export * from './Route' export * from './Route'
export * from './User' export * from './User'
export * from './PreAuthKey'
+38 -80
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
@@ -22,11 +23,6 @@ export interface HeadplaneContext {
write: boolean write: boolean
} }
acl: {
read: boolean
write: boolean
}
oidc?: { oidc?: {
issuer: string issuer: string
client: string client: string
@@ -43,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)
@@ -65,11 +67,11 @@ export async function loadContext(): Promise<HeadplaneContext> {
} }
context = { context = {
debug,
headscaleUrl, headscaleUrl,
cookieSecret, cookieSecret,
integration: await loadIntegration(), integration: await loadIntegration(),
config: contextData, config: contextData,
acl: await checkAcl(config),
oidc: await checkOidc(config), oidc: await checkOidc(config),
} }
@@ -81,61 +83,18 @@ export async function loadContext(): Promise<HeadplaneContext> {
: 'Unavailable', : 'Unavailable',
) )
log.info('CTXT', 'ACL: %s', context.acl.read
? `Found ${context.acl.write ? '' : '(Read Only)'}`
: 'Unavailable',
)
log.info('CTXT', 'OIDC: %s', context.oidc ? 'Configured' : 'Unavailable') log.info('CTXT', 'OIDC: %s', context.oidc ? 'Configured' : 'Unavailable')
return context return context
} }
export async function loadAcl(): Promise<{ data: string, type: 'json' | 'yaml' }> {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await loadConfig()
path = config.acl_policy_path
} catch {}
}
if (!path) {
return { data: '', type: 'json' }
}
const data = await readFile(path, 'utf8')
// Naive check for YAML over JSON
// This is because JSON.parse doesn't support comments
try {
parse(data)
return { data, type: 'yaml' }
} catch {
return { data, type: 'json' }
}
}
export async function patchAcl(data: string) {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await loadConfig()
path = config.acl_policy_path
} catch {}
}
if (!path) {
throw new Error('No ACL file defined')
}
await writeFile(path, data, 'utf8')
}
async function checkConfig(path: string) { 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: {
@@ -147,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,
@@ -160,34 +122,13 @@ async function checkConfig(path: string) {
} }
} }
async function checkAcl(config?: HeadscaleConfig) {
let path = process.env.ACL_FILE
if (!path && config) {
path = config.acl_policy_path
}
let read = false
let write = false
if (path) {
try {
await access(path, constants.R_OK)
read = true
} catch {}
try {
await access(path, constants.W_OK)
write = true
} catch {}
}
return {
read,
write,
}
}
async function 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')
@@ -197,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)
@@ -205,12 +150,23 @@ async function checkOidc(config?: HeadscaleConfig) {
throw new Error('OIDC environment variables are incomplete') throw new Error('OIDC environment variables are incomplete')
} }
if (issuer && client && secret) {
return {
issuer,
client,
secret,
rootKey,
disableKeyLogin,
}
}
if ((!issuer || !client || !secret) && config) { if ((!issuer || !client || !secret) && config) {
issuer = config.oidc?.issuer issuer = config.oidc?.issuer
client = config.oidc?.client_id client = config.oidc?.client_id
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,
@@ -220,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)
}
} }
} }
+32 -13
View File
@@ -45,7 +45,6 @@ const HeadscaleConfig = z.object({
disable_check_updates: goBool.default(false), disable_check_updates: goBool.default(false),
ephemeral_node_inactivity_timeout: goDuration.default('120s'), ephemeral_node_inactivity_timeout: goDuration.default('120s'),
randomize_client_port: goBool.default(false), randomize_client_port: goBool.default(false),
acl_policy_path: z.string().optional(),
acme_email: z.string().optional(), acme_email: z.string().optional(),
acme_url: z.string().optional(), acme_url: z.string().optional(),
@@ -53,6 +52,11 @@ const HeadscaleConfig = z.object({
unix_socket: z.string().default('/var/run/headscale/headscale.sock'), unix_socket: z.string().default('/var/run/headscale/headscale.sock'),
unix_socket_permission: z.string().default('0o770'), unix_socket_permission: z.string().default('0o770'),
policy: z.object({
mode: z.enum(['file', 'database']).default('file'),
path: z.string().optional(),
}).optional(),
tuning: z.object({ tuning: z.object({
batch_change_delay: goDuration.default('800ms'), batch_change_delay: goDuration.default('800ms'),
node_mapsession_buffered_chan_size: z.number().default(30), node_mapsession_buffered_chan_size: z.number().default(30),
@@ -84,18 +88,20 @@ const HeadscaleConfig = z.object({
v6: z.string(), v6: z.string(),
}), }),
dns_config: z.object({ dns: z.object({
override_local_dns: goBool.default(false), magic_dns: goBool.default(true),
nameservers: z.array(z.string()).default([]), base_domain: z.string().default('headscale.net'),
restricted_nameservers: z.record(z.array(z.string())).default({}), nameservers: z.object({
domains: z.array(z.string()).default([]), global: z.array(z.string()).default([]),
split: z.record(z.array(z.string())).default({}),
}).default({ global: [], split: {} }),
search_domains: z.array(z.string()).default([]),
extra_records: z.array(z.object({ extra_records: z.array(z.object({
name: z.string(), name: z.string(),
type: z.literal('A'), type: z.literal('A'),
value: z.string(), value: z.string(),
})).default([]), })).default([]),
magic_dns: goBool.default(false), use_username_in_magic_dns: goBool.default(false),
base_domain: z.string().default('headscale.net'),
}), }),
oidc: z.object({ oidc: z.object({
@@ -181,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,
@@ -220,11 +228,12 @@ export async function loadConfig(path?: string) {
v6: '', v6: '',
}, },
dns_config: loaded.dns_config ?? { dns: loaded.dns ?? {
override_local_dns: false, nameservers: {
nameservers: [], global: [],
restricted_nameservers: {}, split: {},
domains: [], },
search_domains: [],
extra_records: [], extra_records: [],
magic_dns: false, magic_dns: false,
base_domain: 'headscale.net', base_domain: 'headscale.net',
@@ -242,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:')
@@ -272,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.
@@ -301,6 +314,11 @@ export async function patchConfig(partial: Record<string, unknown>) {
// Push the remaining element // Push the remaining element
path.push(temp.replaceAll('"', '')) path.push(temp.replaceAll('"', ''))
if (value === null) {
configYaml.deleteIn(path)
continue
}
configYaml.setIn(path, value) configYaml.setIn(path, value)
} }
@@ -309,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')
} }
+31
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,28 @@ 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)
}
return (response.json() as Promise<T>)
}
export async function put<T>(url: string, key: string, body?: unknown) {
const context = await loadContext()
const prefix = context.headscaleUrl
log.debug('APIC', 'PUT %s', `${prefix}/api/${url}`)
const response = await fetch(`${prefix}/api/${url}`, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
})
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)
} }
@@ -54,6 +82,8 @@ export async function post<T>(url: string, key: string, body?: unknown) {
export async function del<T>(url: string, key: string) { export async function del<T>(url: string, key: string) {
const context = await 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: {
@@ -62,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(
+5 -5
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,
@@ -51,8 +51,8 @@ export async function startOidc(oidc: OidcConfig, req: Request) {
const challenge = await calculatePKCECodeChallenge(verifier) const challenge = await calculatePKCECodeChallenge(verifier)
const callback = new URL('/admin/oidc/callback', req.url) const callback = new URL('/admin/oidc/callback', req.url)
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:' callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
callback.hostname = req.headers.get('Host') ?? '' callback.host = req.headers.get('Host') ?? ''
const authUrl = new URL(processed.authorization_endpoint) const authUrl = new URL(processed.authorization_endpoint)
authUrl.searchParams.set('client_id', oidcClient.client_id) authUrl.searchParams.set('client_id', oidcClient.client_id)
@@ -119,8 +119,8 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
} }
const callback = new URL('/admin/oidc/callback', req.url) const callback = new URL('/admin/oidc/callback', req.url)
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:' callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
callback.hostname = req.headers.get('Host') ?? '' callback.host = req.headers.get('Host') ?? ''
const tokenResponse = await authorizationCodeGrantRequest( const tokenResponse = await authorizationCodeGrantRequest(
processed, processed,
+1 -2
View File
@@ -27,9 +27,8 @@ export const {
maxAge: 60 * 60 * 24, // 24 hours maxAge: 60 * 60 * 24, // 24 hours
path: '/', path: '/',
sameSite: 'lax', sameSite: 'lax',
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secrets: [process.env.COOKIE_SECRET!], secrets: [process.env.COOKIE_SECRET!],
secure: true secure: process.env.COOKIE_SECURE !== 'false',
} }
} }
) )
+1 -2
View File
@@ -2,14 +2,13 @@
# IT IS NOT AN EXAMPLE OF SOMETHING YOU DEPLOY # IT IS NOT AN EXAMPLE OF SOMETHING YOU DEPLOY
# I ONLY USE IT FOR DEVELOPING HEADPLANE # I ONLY USE IT FOR DEVELOPING HEADPLANE
version: '3.9'
networks: networks:
headplane-dev: headplane-dev:
name: 'headplane-dev' name: 'headplane-dev'
driver: 'bridge' driver: 'bridge'
services: services:
headscale: headscale:
image: 'headscale/headscale:0.23.0-alpha5' image: 'headscale/headscale:0.23.0-beta2'
container_name: 'headscale' container_name: 'headscale'
restart: 'unless-stopped' restart: 'unless-stopped'
command: 'serve' command: 'serve'
+4 -4
View File
@@ -54,14 +54,14 @@ When the ACL file is available for editing, the `Access Controls` tab will
become available. All of the integrations support automatic reloading of the become available. All of the integrations support automatic reloading of the
ACLs when the file is changed. ACLs when the file is changed.
> By default, the ACL file is read from `/etc/headscale/acl_policy.json`. This > By default, the ACL file is read from `/etc/headscale/acl_policy.json`.
can be overridden by setting the `ACL_FILE` environment variable and is also > If `policy.path` is set and `policy.mode` is set to `file`, the ACL file will
overriden by the `acl_policy_path` key in the configuration file if set. > be read from the path specified in the configuration file instead.
## Deployment ## Deployment
Requirements: Requirements:
- Headscale 0.23 alpha 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:
+3 -2
View File
@@ -16,7 +16,7 @@ Headplane in a production environment.
## Deployment ## Deployment
Requirements: Requirements:
- Headscale 0.23 alpha 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.2
restart: unless-stopped restart: unless-stopped
ports: ports:
- '3000:3000' - '3000:3000'
@@ -43,6 +43,7 @@ services:
OIDC_ISSUER: 'https://sso.example.com' OIDC_ISSUER: 'https://sso.example.com'
OIDC_CLIENT_SECRET: 'super_secret_client_secret' OIDC_CLIENT_SECRET: 'super_secret_client_secret'
DISABLE_API_KEY_LOGIN: 'true' DISABLE_API_KEY_LOGIN: 'true'
COOKIE_SECURE: 'false'
# These are the default values # These are the default values
HOST: '0.0.0.0' HOST: '0.0.0.0'
+2 -1
View File
@@ -9,11 +9,12 @@ 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`).
- **`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`). - **`HEADSCALE_CONFIG_UNSTRICT`**: This will disable the strict configuration loader (default: `false`).
- **`COOKIE_SECURE`**: This option enables the `Secure` flag for cookies, ensuring they are sent only over HTTPS, which helps prevent interception and enhances data security. It should be disabled when using HTTP instead of HTTPS (default: `true`).
#### Docker Integration #### Docker Integration
The Docker integration allows Headplane to manage the Headscale docker container. The Docker integration allows Headplane to manage the Headscale docker container.
+5 -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-alpha12' 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.2
restart: unless-stopped restart: unless-stopped
volumes: volumes:
- './data:/var/lib/headscale' - './data:/var/lib/headscale'
@@ -68,6 +68,9 @@ services:
HOST: '0.0.0.0' HOST: '0.0.0.0'
PORT: '3000' PORT: '3000'
# Only set this to false if you aren't behind a reverse proxy
COOKIE_SECURE: 'false'
# Overrides the configuration file values if they are set in config.yaml # Overrides the configuration file values if they are set in config.yaml
# If you want to share the same OIDC configuration you do not need this # If you want to share the same OIDC configuration you do not need this
OIDC_CLIENT_ID: 'headscale' OIDC_CLIENT_ID: 'headscale'
+6 -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.2
env: env:
- name: COOKIE_SECRET - name: COOKIE_SECRET
value: 'abcdefghijklmnopqrstuvwxyz' value: 'abcdefghijklmnopqrstuvwxyz'
@@ -98,12 +98,16 @@ spec:
valueFrom: valueFrom:
fieldRef: fieldRef:
fieldPath: metadata.name fieldPath: metadata.name
# Only set this to false if you aren't behind a reverse proxy
- name: COOKIE_SECURE
value: 'false'
volumeMounts: volumeMounts:
- name: headscale-config - name: headscale-config
mountPath: /etc/headscale mountPath: /etc/headscale
- name: headscale - name: headscale
image: headscale/headscale:0.23.0-alpha12 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",
+18 -23
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:
@@ -689,8 +689,8 @@ packages:
'@humanwhocodes/object-schema@2.0.2': '@humanwhocodes/object-schema@2.0.2':
resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==} resolution: {integrity: sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==}
'@internationalized/date@3.5.4': '@internationalized/date@3.5.6':
resolution: {integrity: sha512-qoVJVro+O0rBaw+8HPjUB1iH8Ihf8oziEnqMnvhJUSuVIrHOuZ6eNLHNvzXJKUvAtaDiqMnRlg8Z2mgh09BlUw==} resolution: {integrity: sha512-jLxQjefH9VI5P9UQuqB6qNKnvFt1Ky1TPIzHGsIlCi7sZZoMR8SdYbBGRvM0y+Jtb+ez4ieBzmiAUcpmPYpyOw==}
'@internationalized/message@3.1.4': '@internationalized/message@3.1.4':
resolution: {integrity: sha512-Dygi9hH1s7V9nha07pggCkvmRfDd3q2lWnMGvrJyrOwYMe1yj4D2T9BoH9I6MGR7xz0biQrtLPsqUkqXzIrBOw==} resolution: {integrity: sha512-Dygi9hH1s7V9nha07pggCkvmRfDd3q2lWnMGvrJyrOwYMe1yj4D2T9BoH9I6MGR7xz0biQrtLPsqUkqXzIrBOw==}
@@ -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==}
@@ -5160,7 +5157,7 @@ snapshots:
'@humanwhocodes/object-schema@2.0.2': {} '@humanwhocodes/object-schema@2.0.2': {}
'@internationalized/date@3.5.4': '@internationalized/date@3.5.6':
dependencies: dependencies:
'@swc/helpers': 0.5.11 '@swc/helpers': 0.5.11
@@ -5346,7 +5343,7 @@ snapshots:
'@react-aria/calendar@3.5.8(react-dom@19.0.0-rc-f38c22b244-20240704(react@19.0.0-rc-f38c22b244-20240704))(react@19.0.0-rc-f38c22b244-20240704)': '@react-aria/calendar@3.5.8(react-dom@19.0.0-rc-f38c22b244-20240704(react@19.0.0-rc-f38c22b244-20240704))(react@19.0.0-rc-f38c22b244-20240704)':
dependencies: dependencies:
'@internationalized/date': 3.5.4 '@internationalized/date': 3.5.6
'@react-aria/i18n': 3.11.1(react@19.0.0-rc-f38c22b244-20240704) '@react-aria/i18n': 3.11.1(react@19.0.0-rc-f38c22b244-20240704)
'@react-aria/interactions': 3.21.3(react@19.0.0-rc-f38c22b244-20240704) '@react-aria/interactions': 3.21.3(react@19.0.0-rc-f38c22b244-20240704)
'@react-aria/live-announcer': 3.3.4 '@react-aria/live-announcer': 3.3.4
@@ -5414,7 +5411,7 @@ snapshots:
'@react-aria/datepicker@3.10.1(react-dom@19.0.0-rc-f38c22b244-20240704(react@19.0.0-rc-f38c22b244-20240704))(react@19.0.0-rc-f38c22b244-20240704)': '@react-aria/datepicker@3.10.1(react-dom@19.0.0-rc-f38c22b244-20240704(react@19.0.0-rc-f38c22b244-20240704))(react@19.0.0-rc-f38c22b244-20240704)':
dependencies: dependencies:
'@internationalized/date': 3.5.4 '@internationalized/date': 3.5.6
'@internationalized/number': 3.5.3 '@internationalized/number': 3.5.3
'@internationalized/string': 3.2.3 '@internationalized/string': 3.2.3
'@react-aria/focus': 3.17.1(react@19.0.0-rc-f38c22b244-20240704) '@react-aria/focus': 3.17.1(react@19.0.0-rc-f38c22b244-20240704)
@@ -5516,7 +5513,7 @@ snapshots:
'@react-aria/i18n@3.11.1(react@19.0.0-rc-f38c22b244-20240704)': '@react-aria/i18n@3.11.1(react@19.0.0-rc-f38c22b244-20240704)':
dependencies: dependencies:
'@internationalized/date': 3.5.4 '@internationalized/date': 3.5.6
'@internationalized/message': 3.1.4 '@internationalized/message': 3.1.4
'@internationalized/number': 3.5.3 '@internationalized/number': 3.5.3
'@internationalized/string': 3.2.3 '@internationalized/string': 3.2.3
@@ -5882,7 +5879,7 @@ snapshots:
'@react-stately/calendar@3.5.1(react@19.0.0-rc-f38c22b244-20240704)': '@react-stately/calendar@3.5.1(react@19.0.0-rc-f38c22b244-20240704)':
dependencies: dependencies:
'@internationalized/date': 3.5.4 '@internationalized/date': 3.5.6
'@react-stately/utils': 3.10.1(react@19.0.0-rc-f38c22b244-20240704) '@react-stately/utils': 3.10.1(react@19.0.0-rc-f38c22b244-20240704)
'@react-types/calendar': 3.4.6(react@19.0.0-rc-f38c22b244-20240704) '@react-types/calendar': 3.4.6(react@19.0.0-rc-f38c22b244-20240704)
'@react-types/shared': 3.23.1(react@19.0.0-rc-f38c22b244-20240704) '@react-types/shared': 3.23.1(react@19.0.0-rc-f38c22b244-20240704)
@@ -5939,7 +5936,7 @@ snapshots:
'@react-stately/datepicker@3.9.4(react@19.0.0-rc-f38c22b244-20240704)': '@react-stately/datepicker@3.9.4(react@19.0.0-rc-f38c22b244-20240704)':
dependencies: dependencies:
'@internationalized/date': 3.5.4 '@internationalized/date': 3.5.6
'@internationalized/string': 3.2.3 '@internationalized/string': 3.2.3
'@react-stately/form': 3.0.3(react@19.0.0-rc-f38c22b244-20240704) '@react-stately/form': 3.0.3(react@19.0.0-rc-f38c22b244-20240704)
'@react-stately/overlays': 3.6.7(react@19.0.0-rc-f38c22b244-20240704) '@react-stately/overlays': 3.6.7(react@19.0.0-rc-f38c22b244-20240704)
@@ -6125,7 +6122,7 @@ snapshots:
'@react-types/calendar@3.4.6(react@19.0.0-rc-f38c22b244-20240704)': '@react-types/calendar@3.4.6(react@19.0.0-rc-f38c22b244-20240704)':
dependencies: dependencies:
'@internationalized/date': 3.5.4 '@internationalized/date': 3.5.6
'@react-types/shared': 3.23.1(react@19.0.0-rc-f38c22b244-20240704) '@react-types/shared': 3.23.1(react@19.0.0-rc-f38c22b244-20240704)
react: 19.0.0-rc-f38c22b244-20240704 react: 19.0.0-rc-f38c22b244-20240704
@@ -6147,7 +6144,7 @@ snapshots:
'@react-types/datepicker@3.7.4(react@19.0.0-rc-f38c22b244-20240704)': '@react-types/datepicker@3.7.4(react@19.0.0-rc-f38c22b244-20240704)':
dependencies: dependencies:
'@internationalized/date': 3.5.4 '@internationalized/date': 3.5.6
'@react-types/calendar': 3.4.6(react@19.0.0-rc-f38c22b244-20240704) '@react-types/calendar': 3.4.6(react@19.0.0-rc-f38c22b244-20240704)
'@react-types/overlays': 3.8.7(react@19.0.0-rc-f38c22b244-20240704) '@react-types/overlays': 3.8.7(react@19.0.0-rc-f38c22b244-20240704)
'@react-types/shared': 3.23.1(react@19.0.0-rc-f38c22b244-20240704) '@react-types/shared': 3.23.1(react@19.0.0-rc-f38c22b244-20240704)
@@ -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: {}
@@ -9210,7 +9205,7 @@ snapshots:
react-aria-components@1.2.1(react-dom@19.0.0-rc-f38c22b244-20240704(react@19.0.0-rc-f38c22b244-20240704))(react@19.0.0-rc-f38c22b244-20240704): react-aria-components@1.2.1(react-dom@19.0.0-rc-f38c22b244-20240704(react@19.0.0-rc-f38c22b244-20240704))(react@19.0.0-rc-f38c22b244-20240704):
dependencies: dependencies:
'@internationalized/date': 3.5.4 '@internationalized/date': 3.5.6
'@internationalized/string': 3.2.3 '@internationalized/string': 3.2.3
'@react-aria/color': 3.0.0-beta.33(react-dom@19.0.0-rc-f38c22b244-20240704(react@19.0.0-rc-f38c22b244-20240704))(react@19.0.0-rc-f38c22b244-20240704) '@react-aria/color': 3.0.0-beta.33(react-dom@19.0.0-rc-f38c22b244-20240704(react@19.0.0-rc-f38c22b244-20240704))(react@19.0.0-rc-f38c22b244-20240704)
'@react-aria/focus': 3.17.1(react@19.0.0-rc-f38c22b244-20240704) '@react-aria/focus': 3.17.1(react@19.0.0-rc-f38c22b244-20240704)
+102 -39
View File
@@ -186,7 +186,8 @@ log:
# Path to a file containg ACL policies. # Path to a file containg ACL policies.
# ACLs can be defined as YAML or HUJSON. # ACLs can be defined as YAML or HUJSON.
# https://tailscale.com/kb/1018/acls/ # https://tailscale.com/kb/1018/acls/
acl_policy_path: /etc/headscale/acl.json policy:
mode: 'database'
## DNS ## DNS
# #
@@ -197,7 +198,7 @@ acl_policy_path: /etc/headscale/acl.json
# - https://tailscale.com/kb/1081/magicdns/ # - https://tailscale.com/kb/1081/magicdns/
# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/ # - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/
# #
dns_config: dns_config2:
# Whether to prefer using Headscale provided DNS or use local. # Whether to prefer using Headscale provided DNS or use local.
override_local_dns: true override_local_dns: true
@@ -253,6 +254,68 @@ dns_config:
# The FQDN of the hosts will be # The FQDN of the hosts will be
# `hostname.user.base_domain` (e.g., _myhost.myuser.example.com_). # `hostname.user.base_domain` (e.g., _myhost.myuser.example.com_).
base_domain: ts.net base_domain: ts.net
extra_records:
- name: test.example.com
type: A
value: 1.1.1.1
dns:
# Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/).
# Only works if there is at least a nameserver defined.
magic_dns: true
# Defines the base domain to create the hostnames for MagicDNS.
# This domain _must_ be different from the server_url domain.
# `base_domain` must be a FQDN, without the trailing dot.
# The FQDN of the hosts will be
# `hostname.base_domain` (e.g., _myhost.example.com_).
base_domain: example.com
# List of DNS servers to expose to clients.
nameservers:
global:
- 1.1.1.1
- 1.0.0.1
- 2606:4700:4700::1111
- 2606:4700:4700::1001
# NextDNS (see https://tailscale.com/kb/1218/nextdns/).
# "abc123" is example NextDNS ID, replace with yours.
# - https://dns.nextdns.io/abc123
# Split DNS (see https://tailscale.com/kb/1054/dns/),
# a map of domains and which DNS server to use for each.
split:
{}
# foo.bar.com:
# - 1.1.1.1
# darp.headscale.net:
# - 1.1.1.1
# - 8.8.8.8
# Set custom DNS search domains. With MagicDNS enabled,
# your tailnet base_domain is always the first search domain.
search_domains: []
# Extra DNS records
# so far only A-records are supported (on the tailscale side)
# See https://github.com/juanfont/headscale/blob/main/docs/dns-records.md#Limitations
extra_records: []
# - name: "grafana.myvpn.example.com"
# type: "A"
# value: "100.64.0.3"
#
# # you can also put it in one line
# - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.3" }
# DEPRECATED
# Use the username as part of the DNS name for nodes, with this option enabled:
# node1.username.example.com
# while when this is disabled:
# node1.example.com
# This is a legacy option as Headscale has have this wrongly implemented
# while in upstream Tailscale, the username is not included.
use_username_in_magic_dns: false
# Unix socket used for the CLI to connect without authentication # Unix socket used for the CLI to connect without authentication
# Note: for production you will want to set this to something like: # Note: for production you will want to set this to something like:
@@ -268,46 +331,46 @@ oidc:
issuer: "https://sso.example.com" issuer: "https://sso.example.com"
client_id: "headscale" client_id: "headscale"
client_secret: "super_secret_client_secret" client_secret: "super_secret_client_secret"
# # Alternatively, set `client_secret_path` to read the secret from the file. # # Alternatively, set `client_secret_path` to read the secret from the file.
# # It resolves environment variables, making integration to systemd's # # It resolves environment variables, making integration to systemd's
# # `LoadCredential` straightforward: # # `LoadCredential` straightforward:
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret" # client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
# # client_secret and client_secret_path are mutually exclusive. # # client_secret and client_secret_path are mutually exclusive.
# #
# # The amount of time from a node is authenticated with OpenID until it # # The amount of time from a node is authenticated with OpenID until it
# # expires and needs to reauthenticate. # # expires and needs to reauthenticate.
# # Setting the value to "0" will mean no expiry. # # Setting the value to "0" will mean no expiry.
expiry: 180d expiry: 180d
# #
# # Use the expiry from the token received from OpenID when the user logged # # Use the expiry from the token received from OpenID when the user logged
# # in, this will typically lead to frequent need to reauthenticate and should # # in, this will typically lead to frequent need to reauthenticate and should
# # only been enabled if you know what you are doing. # # only been enabled if you know what you are doing.
# # Note: enabling this will cause `oidc.expiry` to be ignored. # # Note: enabling this will cause `oidc.expiry` to be ignored.
# use_expiry_from_token: false # use_expiry_from_token: false
# #
# # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query # # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query
# # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email". # # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".
# #
# scope: ["openid", "profile", "email", "custom"] # scope: ["openid", "profile", "email", "custom"]
# extra_params: # extra_params:
# domain_hint: example.com # domain_hint: example.com
# #
# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the # # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
# # authentication request will be rejected. # # authentication request will be rejected.
# #
allowed_domains: allowed_domains:
- example.com - example.com
# # Note: Groups from keycloak have a leading '/' # # Note: Groups from keycloak have a leading '/'
# allowed_groups: # allowed_groups:
# - /headscale # - /headscale
# allowed_users: # allowed_users:
# - alice@example.com # - alice@example.com
# #
# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed. # # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
# # This will transform `first-name.last-name@example.com` to the user `first-name.last-name` # # This will transform `first-name.last-name@example.com` to the user `first-name.last-name`
# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following # # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
# user: `first-name.last-name.example.com` # user: `first-name.last-name.example.com`
# #
strip_email_domain: true strip_email_domain: true
# Logtail configuration # Logtail configuration