Compare commits

...

24 Commits

Author SHA1 Message Date
Aarnav Tale 2229f547a9 chore: v0.3.1 2024-10-03 15:31:03 -04:00
Aarnav Tale 65cc278a59 docs(TALE-33): document debug 2024-10-03 15:30:24 -04:00
Aarnav Tale 1555846df2 fix(TALE-34): use the http coded URL for socket 2024-10-03 15:15:56 -04:00
Aarnav Tale e8c1cadf54 feat(TALE-35): add initial machine key authorization 2024-10-03 11:58:05 -04:00
Aarnav Tale d867769025 chore: update browserlist 2024-10-02 13:41:45 -04:00
Aarnav Tale 1d6066d3f0 feat(TALE-33): add debug logging with DEBUG env 2024-10-02 13:33:39 -04:00
Aarnav Tale 1d821251a9 fix(TALE-34): setting url.protocol does not work anymore 2024-10-02 12:59:42 -04:00
Aarnav Tale a0d6905123 chore: v0.3.0 2024-09-25 16:32:58 -04:00
Aarnav Tale 4095ed2a68 docs: version the docker images 2024-09-25 16:26:19 -04:00
Aarnav Tale 58e98278d1 chore: support dns use_username_in_magic_dns config 2024-09-25 16:22:00 -04:00
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
41 changed files with 674 additions and 351 deletions
+21
View File
@@ -1,3 +1,24 @@
### 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)
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
- 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 {
readonly data?: {
acl: HeadplaneContext['acl']
config: HeadplaneContext['config']
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">
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
{data?.acl.read
? (
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
)
: undefined}
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
{data?.config.read
? (
<>
+14 -7
View File
@@ -1,15 +1,22 @@
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 (
<div className={clsx(
'p-4 rounded-md w-fit flex items-center gap-3',
'bg-slate-400 dark:bg-slate-700'
<div className={cn(
'p-4 rounded-md w-full flex items-center gap-3',
'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}
</div>
)
+18 -36
View File
@@ -24,6 +24,7 @@ export default createIntegration<Context>({
isAvailable: async (context) => {
// Check for the HEADSCALE_CONTAINER environment variable first
// to avoid unnecessary fetching of the Docker socket
log.debug('INTG', 'Checking Docker integration availability')
context.container = process.env.HEADSCALE_CONTAINER
?.trim()
.toLowerCase()
@@ -54,16 +55,19 @@ export default createIntegration<Context>({
// The API is available as an HTTP endpoint and this
// will simplify the fetching logic in undici
if (url.protocol === 'tcp:') {
url.protocol = 'http:'
// Apparently setting url.protocol doesn't work anymore?
const fetchU = url.href.replace(url.protocol, 'http:')
try {
log.info('INTG', 'Checking API: %s', url.href)
await fetch(new URL('/v1.30/version', url).href)
} catch {
log.info('INTG', 'Checking API: %s', fetchU)
await fetch(new URL('/v1.30/version', fetchU).href)
} catch (error) {
log.debug('INTG', 'Failed to connect to Docker API', error)
log.error('INTG', 'Failed to connect to Docker API')
return false
}
context.client = new Client(url.href)
context.client = new Client(fetchU)
}
// Check if the socket is accessible
@@ -73,7 +77,8 @@ export default createIntegration<Context>({
url.pathname,
)
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',
path,
)
@@ -88,36 +93,6 @@ export default createIntegration<Context>({
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) => {
if (!context.client || !context.container) {
return
@@ -127,6 +102,12 @@ export default createIntegration<Context>({
let attempts = 0
while (attempts <= context.maxAttempts) {
log.debug(
'INTG', 'Restarting container: %s (attempt %d)',
context.container,
attempts,
)
const response = await context.client.request({
method: 'POST',
path: `/v1.30/containers/${context.container}/restart`,
@@ -150,6 +131,7 @@ export default createIntegration<Context>({
attempts = 0
while (attempts <= context.maxAttempts) {
try {
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts)
await pull('v1', '')
return
} catch (error) {
-1
View File
@@ -3,7 +3,6 @@ export interface IntegrationFactory<T = any> {
name: string
context: T
isAvailable: (context: T) => Promise<boolean> | boolean
onAclChange?: (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
try {
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot)
const files = await readdir(svcRoot)
if (files.length === 0) {
log.error('INTG', 'Kubernetes service account not found')
@@ -39,6 +40,7 @@ export default createIntegration<Context>({
Config.SERVICEACCOUNT_NAMESPACE_PATH,
]
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '))
if (!expectedFiles.every(file => mappedFiles.has(file))) {
log.error('INTG', 'Malformed Kubernetes service account')
return false
@@ -48,6 +50,7 @@ export default createIntegration<Context>({
return false
}
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot)
const namespace = await readFile(
Config.SERVICEACCOUNT_NAMESPACE_PATH,
'utf8',
@@ -68,7 +71,13 @@ export default createIntegration<Context>({
return false
}
log.debug('INTG', 'Checking Kubernetes pod %s in namespace %s',
pod,
namespace,
)
try {
log.debug('INTG', 'Attempgin to get cluster KubeConfig')
const kc = new KubeConfig()
kc.loadFromCluster()
@@ -91,6 +100,7 @@ export default createIntegration<Context>({
kCoreV1Api.basePath,
)
log.debug('INTG', 'Reading pod info for %s', pod)
const { response, body } = await kCoreV1Api.readNamespacedPod(
pod,
namespace,
@@ -103,6 +113,7 @@ export default createIntegration<Context>({
return false
}
log.debug('INTG', 'Got pod info: %o', body.spec)
const shared = body.spec?.shareProcessNamespace
if (shared === undefined) {
log.error(
@@ -127,6 +138,7 @@ export default createIntegration<Context>({
}
}
log.debug('INTG', 'Looking for namespaced process in /proc')
const dir = resolve('/proc')
try {
const subdirs = await readdir(dir)
@@ -139,11 +151,14 @@ export default createIntegration<Context>({
const path = join('/proc', dir, 'cmdline')
try {
log.debug('INTG', 'Reading %s', path)
const data = await readFile(path, 'utf8')
if (data.includes('headscale')) {
return pid
}
} catch {}
} catch (error) {
log.debug('INTG', 'Failed to read %s: %s', path, error)
}
})
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) {
log.error('INTG', 'Found %d Headscale processes: %s',
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) => {
if (!context.pid) {
return
+7 -11
View File
@@ -22,6 +22,7 @@ export default createIntegration<Context>({
return false
}
log.debug('INTG', 'Checking /proc for Headscale process')
const dir = resolve('/proc')
try {
const subdirs = await readdir(dir)
@@ -34,11 +35,14 @@ export default createIntegration<Context>({
const path = join('/proc', dir, 'cmdline')
try {
log.debug('INTG', 'Reading %s', path)
const data = await readFile(path, 'utf8')
if (data.includes('headscale')) {
return pid
}
} catch {}
} catch (error) {
log.error('INTG', 'Failed to read %s: %s', path, error)
}
})
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) {
log.error('INTG', 'Found %d Headscale processes: %s',
pids.length,
@@ -70,14 +75,5 @@ export default createIntegration<Context>({
log.error('INTG', 'Failed to read /proc')
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 { cn } from '~/utils/cn'
interface MonacoProps {
variant: 'editor' | 'diff'
interface Props {
variant: 'edit' | 'diff'
language: 'json' | 'yaml'
value: string
onChange: (value: string) => void
original?: string
state: [string, (value: string) => void]
policy?: string
isDisabled?: boolean
}
function monacoCallback(monaco: Monaco) {
@@ -26,7 +26,7 @@ function monacoCallback(monaco: Monaco) {
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)
useEffect(() => {
@@ -46,29 +46,30 @@ export default function MonacoEditor({ value, onChange, variant, original, langu
)}
>
<div className="overflow-y-scroll h-editor text-sm">
<ClientOnly fallback={<Fallback acl={value} />}>
{() => variant === 'editor'
<ClientOnly fallback={<Fallback acl={state[0]} />}>
{() => variant === 'edit'
? (
<Editor
height="100%"
language={language}
theme={light ? 'light' : 'vs-dark'}
value={value}
value={state[0]}
onChange={(updated) => {
if (!updated) {
return
}
if (updated !== value) {
onChange(updated)
if (updated !== state[0]) {
state[1](updated)
}
}}
loading={<Fallback acl={value} />}
loading={<Fallback acl={state[0]} />}
beforeMount={monacoCallback}
options={{
wordWrap: 'on',
minimap: { enabled: false },
fontSize: 14,
readOnly: isDisabled,
}}
/>
)
@@ -77,14 +78,15 @@ export default function MonacoEditor({ value, onChange, variant, original, langu
height="100%"
language={language}
theme={light ? 'light' : 'vs-dark'}
original={original}
modified={value}
loading={<Fallback acl={value} />}
original={policy}
modified={state[0]}
loading={<Fallback acl={state[0]} />}
beforeMount={monacoCallback}
options={{
wordWrap: 'on',
minimap: { enabled: false },
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 { type ActionFunctionArgs, json } from '@remix-run/node'
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
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 { setTimeout } from 'node:timers/promises'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Link from '~/components/Link'
import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
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 Monaco from './editor'
export async function loader() {
const context = await loadContext()
if (!context.acl.read) {
throw new Error('No ACL configuration is available')
}
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
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 {
hasAclWrite: context.acl.write,
currentAcl: data,
aclType: type,
}
hasAclWrite: true,
currentAcl: '',
aclType: 'json',
} as const
}
export async function action({ request }: ActionFunctionArgs) {
@@ -37,27 +70,102 @@ export async function action({ request }: ActionFunctionArgs) {
})
}
const context = await loadContext()
if (!context.acl.write) {
return json({ success: false }, {
status: 403,
const { acl } = await request.json() as { acl: string, api: boolean }
try {
await put('v1/policy', session.get('hsApiKey')!, {
policy: acl,
})
}
const data = await request.json() as { acl: string }
await patchAcl(data.acl)
if (context.integration?.onAclChange) {
await context.integration.onAclChange(context.integration.context)
await setTimeout(250)
return json({ success: true })
} catch (error) {
return json({ success: false }, {
status: error instanceof HeadscaleError ? error.status : 500,
})
}
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() {
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher<typeof action>()
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 (
<div>
@@ -65,9 +173,12 @@ export default function Page() {
? undefined
: (
<div className="mb-4">
<Notice>
The ACL policy file is readonly to Headplane.
You will not be able to make changes here.
<Notice className="w-fit">
The ACL policy is read-only. You can view the current policy
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>
</div>
)}
@@ -144,19 +255,18 @@ export default function Page() {
</TabList>
<TabPanel id="edit">
<Monaco
variant="editor"
isDisabled={!data.hasAclWrite}
variant="edit"
language={data.aclType}
value={acl}
onChange={setAcl}
state={[acl, setAcl]}
/>
</TabPanel>
<TabPanel id="diff">
<Monaco
variant="diff"
language={data.aclType}
value={acl}
onChange={setAcl}
original={data.currentAcl}
state={[acl, setAcl]}
policy={data.currentAcl}
/>
</TabPanel>
<TabPanel id="preview">
@@ -180,14 +290,13 @@ export default function Page() {
className="mr-2"
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
onPress={() => {
setToasted(false)
fetcher.submit({
acl,
}, {
method: 'PATCH',
encType: 'application/json',
})
toast('Updated tailnet ACL policy')
}}
>
{fetcher.state === 'idle'
@@ -197,7 +306,10 @@ export default function Page() {
)}
Save
</Button>
<Button onPress={() => { setAcl(data.currentAcl) }}>
<Button
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl || !data.hasAclWrite}
onPress={() => { setAcl(data.currentAcl) }}
>
Discard Changes
</Button>
</div>
+1 -1
View File
@@ -47,7 +47,7 @@ export default function AddDNS({ records }: Props) {
setIp('')
submit({
'dns_config.extra_records': [
'dns.extra_records': [
...records,
{
name,
@@ -55,7 +55,7 @@ export default function AddNameserver({ nameservers }: Props) {
}
submit({
'dns_config.restricted_nameservers': splitNs,
'dns.nameservers.split': splitNs,
}, {
method: 'PATCH',
encType: 'application/json',
@@ -65,7 +65,7 @@ export default function AddNameserver({ nameservers }: Props) {
globalNs.push(ns)
submit({
'dns_config.nameservers': globalNs,
'dns.nameservers.global': globalNs,
}, {
method: 'PATCH',
encType: 'application/json',
+1 -1
View File
@@ -63,7 +63,7 @@ export default function DNS({ records, isDisabled }: Props) {
isDisabled={isDisabled}
onPress={() => {
submit({
'dns_config.extra_records': records
'dns.extra_records': records
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
+2 -3
View File
@@ -134,7 +134,7 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': [...localDomains, newDomain]
'dns.search_domains': [...localDomains, newDomain]
}, {
method: 'PATCH',
encType: 'application/json'
@@ -212,8 +212,7 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
isDisabled={disabled}
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': localDomains.filter((_, index) => index !== id - 1)
'dns.search_domains': localDomains.filter((_, index) => index !== id - 1)
}, {
method: 'PATCH',
encType: 'application/json'
+1 -1
View File
@@ -42,7 +42,7 @@ export default function Modal({ isEnabled, disabled }: Properties) {
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.magic_dns': !isEnabled
'dns.magic_dns': !isEnabled
}, {
method: 'PATCH',
encType: 'application/json'
+6 -34
View File
@@ -11,11 +11,10 @@ import AddNameserver from './dialogs/nameserver'
interface Props {
nameservers: Record<string, string[]>
override: boolean
isDisabled: boolean
}
export default function Nameservers({ nameservers, override, isDisabled }: Props) {
export default function Nameservers({ nameservers, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<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'}
isDisabled={isDisabled}
nameservers={nameservers[key]}
override={override}
name={key}
/>
))}
@@ -57,11 +55,9 @@ interface ListProps {
isDisabled: boolean
nameservers: string[]
name: string
override: boolean
}
function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: ListProps) {
const [localOverride, setLocalOverride] = useState(override)
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
const submit = useSubmit()
return (
@@ -70,30 +66,6 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: L
<h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name}
</h2>
{isGlobal
? (
<div className="flex gap-2 items-center">
<span className="text-sm opacity-50">
Override local DNS
</span>
<Switch
label="Override local DNS"
defaultSelected={localOverride}
isDisabled={isDisabled}
onChange={() => {
submit({
'dns_config.override_local_dns': !localOverride,
}, {
method: 'PATCH',
encType: 'application/json',
})
setLocalOverride(!localOverride)
}}
/>
</div>
)
: undefined}
</div>
<TableList>
{nameservers.map((ns, index) => (
@@ -111,17 +83,17 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: L
onPress={() => {
if (isGlobal) {
submit({
'dns_config.nameservers': nameservers
'dns.nameservers.global': nameservers
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
} else {
const key = `dns_config.restricted_nameservers."${name}"`
const key = `dns.nameservers.split."${name}"`
const list = nameservers.filter((_, i) => i !== index)
submit({
[key]: nameservers
.filter((_, i) => i !== index),
[key]: list.length ? list : null,
}, {
method: 'PATCH',
encType: 'application/json',
+2 -2
View File
@@ -28,7 +28,7 @@ export default function Modal({ name, disabled }: Properties) {
Devices are accessible at
{' '}
<Code>
[device].[user].{name}
[device].{name}
</Code>
{' '}
when Magic DNS is enabled.
@@ -80,7 +80,7 @@ export default function Modal({ name, disabled }: Properties) {
variant='confirm'
onPress={() => {
fetcher.submit({
'dns_config.base_domain': newName
'dns.base_domain': newName
}, {
method: 'PATCH',
encType: 'application/json'
+9 -9
View File
@@ -24,13 +24,14 @@ export async function loader() {
const config = await loadConfig()
const dns = {
prefixes: config.prefixes,
magicDns: config.dns_config.magic_dns,
baseDomain: config.dns_config.base_domain,
overrideLocal: config.dns_config.override_local_dns,
nameservers: config.dns_config.nameservers,
splitDns: config.dns_config.restricted_nameservers,
searchDomains: config.dns_config.domains,
extraRecords: config.dns_config.extra_records,
magicDns: config.dns.magic_dns,
baseDomain: config.dns.use_username_in_magic_dns
? `[user].${config.dns.base_domain}`
: config.dns.base_domain,
nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
extraRecords: config.dns.extra_records,
}
return {
@@ -87,7 +88,6 @@ export default function Page() {
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
<Nameservers
nameservers={allNs}
override={data.overrideLocal}
isDisabled={!data.config.write}
/>
@@ -109,7 +109,7 @@ export default function Page() {
on the tailnet. Devices will be accessible at
{' '}
<Code>
[device].[user].
[device].
{data.baseDomain}
</Code>
{' '}
+2 -2
View File
@@ -27,8 +27,8 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
if (context.config.read) {
const config = await loadConfig()
if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain
if (config.dns.magic_dns) {
magic = config.dns.base_domain
}
}
@@ -97,6 +97,35 @@ export async function menuAction(request: ActionFunctionArgs['request']) {
}
}
case 'register': {
const key = data.get('mkey')?.toString()
const user = data.get('user')?.toString()
if (!key) {
return json({ message: 'No machine key provided' }, {
status: 400,
})
}
if (!user) {
return json({ message: 'No user provided' }, {
status: 400,
})
}
try {
await post('v1/node/register', session.get('hsApiKey')!, {
user, key,
})
return json({ message: 'Machine registered' })
} catch {
return json({ message: 'Failed to register machine' }, {
status: 500,
})
}
}
default: {
return json({ message: 'Invalid method' }, {
status: 400,
@@ -0,0 +1,115 @@
import { Form, useSubmit } from '@remix-run/react'
import { Dispatch, SetStateAction, useState } from 'react'
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import Select from '~/components/Select'
import Menu from '~/components/Menu'
import { Machine, User } from '~/types'
export interface NewProps {
server: string
users: User[]
}
export default function New(data: NewProps) {
const submit = useSubmit()
const mkeyState = useState(false)
const pkeyState = useState(false)
const [mkey, setMkey] = useState('')
const [user, setUser] = useState(data.users[0].id)
return (
<>
<Dialog>
<Dialog.Panel control={mkeyState}>
{close => (
<>
<Dialog.Title>
Register Machine Key
</Dialog.Title>
<Dialog.Text className='mb-4'>
The machine key is given when you run
{' '}
<Code>
tailscale up --login-server=
</Code>
<Code>
{data.server}
</Code>
{' '}
on your device.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<TextField
label='Machine Key'
placeholder='nodekey:ff.....'
name="mkey"
state={[mkey, setMkey]}
className='my-2 font-mono'
/>
<Select
label="Owner"
name="user"
placeholder="Select a user"
state={[user, setUser]}
>
{data.users.map(user => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
>
Register
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
<Menu>
<Menu.Button
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
'hover:bg-main-800 dark:hover:bg-main-700',
)}
>
Add Device
</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={mkeyState}>
<ServerIcon className='w-4 h-4 mr-2'/>
Register Machine Key
</Menu.ItemButton>
<Menu.ItemButton control={pkeyState} isDisabled>
<KeyIcon className='w-4 h-4 mr-2'/>
Generate Pre-auth Key
</Menu.ItemButton>
</Menu.Items>
</Menu>
</>
)
}
+6 -4
View File
@@ -31,6 +31,10 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
tags.unshift('Expired')
}
let prefix = magic?.startsWith('[user]')
? magic.replace('[user]', machine.user.name)
: magic
return (
<tr
key={machine.id}
@@ -102,16 +106,14 @@ export default function MachineRow({ machine, routes, magic, users }: Props) {
'justify-between w-full break-keep',
)}
onPress={async () => {
const ip = `${machine.givenName}.${machine.user.name}.${magic}`
const ip = `${machine.givenName}.${prefix}`
await navigator.clipboard.writeText(ip)
toast('Copied hostname to clipboard')
}}
>
{machine.givenName}
.
{machine.user.name}
.
{magic}
{prefix}
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
)
+13 -4
View File
@@ -15,6 +15,7 @@ import { useLiveData } from '~/utils/useLiveData'
import { menuAction } from './action'
import MachineRow from './machine'
import NewMachine from './dialogs/new'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
@@ -29,8 +30,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
if (context.config.read) {
const config = await loadConfig()
if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain
if (config.dns.magic_dns) {
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,
users: users.users,
magic,
server: context.headscaleUrl,
}
}
@@ -52,7 +58,10 @@ export default function Page() {
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">
<thead className="text-gray-500 dark:text-gray-400">
<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
{' '}
<Code>
[name].[user].
[name].
{data.magic}
</Code>
</Tooltip>
-1
View File
@@ -35,7 +35,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext()
return {
acl: context.acl,
config: context.config,
user: session.get('user'),
}
-1
View File
@@ -36,7 +36,6 @@ export default function Add({ magic }: Props) {
{' '}
<Code>
[machine].
{username.length > 0 ? username : '[username]'}
.
{magic}
</Code>
-1
View File
@@ -59,7 +59,6 @@ export default function Rename({ username, magic }: Props) {
{' '}
<Code>
[machine].
{newName.length > 0 ? newName : '[new-username]'}
.
{magic}
</Code>
+2 -2
View File
@@ -41,8 +41,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
if (context.config.read) {
const config = await loadConfig()
if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain
if (config.dns.magic_dns) {
magic = config.dns.base_domain
}
}
+38 -80
View File
@@ -13,6 +13,7 @@ import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale'
import log from '~/utils/log'
export interface HeadplaneContext {
debug: boolean
headscaleUrl: string
cookieSecret: string
integration: IntegrationFactory | undefined
@@ -22,11 +23,6 @@ export interface HeadplaneContext {
write: boolean
}
acl: {
read: boolean
write: boolean
}
oidc?: {
issuer: string
client: string
@@ -43,6 +39,12 @@ export async function loadContext(): Promise<HeadplaneContext> {
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 { config, contextData } = await checkConfig(path)
@@ -65,11 +67,11 @@ export async function loadContext(): Promise<HeadplaneContext> {
}
context = {
debug,
headscaleUrl,
cookieSecret,
integration: await loadIntegration(),
config: contextData,
acl: await checkAcl(config),
oidc: await checkOidc(config),
}
@@ -81,61 +83,18 @@ export async function loadContext(): Promise<HeadplaneContext> {
: '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')
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) {
log.debug('CTXT', 'Checking config at %s', path)
let config: HeadscaleConfig | undefined
try {
config = await loadConfig(path)
} catch {
log.debug('CTXT', 'Config at %s failed to load', path)
return {
config: undefined,
contextData: {
@@ -147,9 +106,12 @@ async function checkConfig(path: string) {
let write = false
try {
log.debug('CTXT', 'Checking write access to %s', path)
await access(path, constants.W_OK)
write = true
} catch {}
} catch {
log.debug('CTXT', 'No write access to %s', path)
}
return {
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) {
log.debug('CTXT', 'Checking OIDC configuration')
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
if (!rootKey) {
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 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 (
(issuer ?? client ?? secret)
&& !(issuer && client && secret)
@@ -205,12 +150,23 @@ async function checkOidc(config?: HeadscaleConfig) {
throw new Error('OIDC environment variables are incomplete')
}
if (issuer && client && secret) {
return {
issuer,
client,
secret,
rootKey,
disableKeyLogin,
}
}
if ((!issuer || !client || !secret) && config) {
issuer = config.oidc?.issuer
client = config.oidc?.client_id
secret = config.oidc?.client_secret
if (!secret && config.oidc?.client_secret_path) {
log.debug('CTXT', 'Trying to read OIDC client secret from %s', config.oidc.client_secret_path)
try {
const data = await readFile(
config.oidc.client_secret_path,
@@ -220,7 +176,9 @@ async function checkOidc(config?: HeadscaleConfig) {
if (data && data.length > 0) {
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),
ephemeral_node_inactivity_timeout: goDuration.default('120s'),
randomize_client_port: goBool.default(false),
acl_policy_path: z.string().optional(),
acme_email: 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_permission: z.string().default('0o770'),
policy: z.object({
mode: z.enum(['file', 'database']).default('file'),
path: z.string().optional(),
}).optional(),
tuning: z.object({
batch_change_delay: goDuration.default('800ms'),
node_mapsession_buffered_chan_size: z.number().default(30),
@@ -84,18 +88,20 @@ const HeadscaleConfig = z.object({
v6: z.string(),
}),
dns_config: z.object({
override_local_dns: goBool.default(false),
nameservers: z.array(z.string()).default([]),
restricted_nameservers: z.record(z.array(z.string())).default({}),
domains: z.array(z.string()).default([]),
dns: z.object({
magic_dns: goBool.default(true),
base_domain: z.string().default('headscale.net'),
nameservers: z.object({
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({
name: z.string(),
type: z.literal('A'),
value: z.string(),
})).default([]),
magic_dns: goBool.default(false),
base_domain: z.string().default('headscale.net'),
use_username_in_magic_dns: goBool.default(false),
}),
oidc: z.object({
@@ -181,10 +187,12 @@ export async function loadConfig(path?: string) {
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')
configYaml = parseDocument(data)
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>
config = {
...loaded,
@@ -220,11 +228,12 @@ export async function loadConfig(path?: string) {
v6: '',
},
dns_config: loaded.dns_config ?? {
override_local_dns: false,
nameservers: [],
restricted_nameservers: {},
domains: [],
dns: loaded.dns ?? {
nameservers: {
global: [],
split: {},
},
search_domains: [],
extra_records: [],
magic_dns: false,
base_domain: 'headscale.net',
@@ -242,8 +251,10 @@ export async function loadConfig(path?: string) {
}
try {
log.debug('CFGX', 'Attempting to parse Headscale configuration')
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
} catch (error) {
log.debug('CFGX', 'Failed to load Headscale configuration')
if (error instanceof z.ZodError) {
log.error('CFGX', 'Recieved invalid configuration file')
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')
}
log.debug('CFGX', 'Patching Headscale configuration')
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
// the foo.bar as a single key, and not as two keys, so that needs
// to be split correctly.
@@ -301,6 +314,11 @@ export async function patchConfig(partial: Record<string, unknown>) {
// Push the remaining element
path.push(temp.replaceAll('"', ''))
if (value === null) {
configYaml.deleteIn(path)
continue
}
configYaml.setIn(path, value)
}
@@ -309,5 +327,6 @@ export async function patchConfig(partial: Record<string, unknown>) {
: (await HeadscaleConfig.parseAsync(configYaml.toJSON()))
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')
}
+31
View File
@@ -1,4 +1,5 @@
import { loadContext } from './config/headplane'
import log from './log'
export class HeadscaleError extends Error {
status: number
@@ -20,6 +21,8 @@ export class FatalError extends Error {
export async function pull<T>(url: string, key: string) {
const context = await loadContext()
const prefix = context.headscaleUrl
log.debug('APIC', 'GET %s', `${prefix}/api/${url}`)
const response = await fetch(`${prefix}/api/${url}`, {
headers: {
Authorization: `Bearer ${key}`,
@@ -27,6 +30,7 @@ export async function pull<T>(url: string, key: string) {
})
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)
}
@@ -36,6 +40,8 @@ export async function pull<T>(url: string, key: string) {
export async function post<T>(url: string, key: string, body?: unknown) {
const context = await loadContext()
const prefix = context.headscaleUrl
log.debug('APIC', 'POST %s', `${prefix}/api/${url}`)
const response = await fetch(`${prefix}/api/${url}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
@@ -45,6 +51,28 @@ export async function post<T>(url: string, key: string, body?: unknown) {
})
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)
}
@@ -54,6 +82,8 @@ export async function post<T>(url: string, key: string, body?: unknown) {
export async function del<T>(url: string, key: string) {
const context = await loadContext()
const prefix = context.headscaleUrl
log.debug('APIC', 'DELETE %s', `${prefix}/api/${url}`)
const response = await fetch(`${prefix}/api/${url}`, {
method: 'DELETE',
headers: {
@@ -62,6 +92,7 @@ export async function del<T>(url: string, key: string) {
})
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)
}
+6
View File
@@ -10,6 +10,12 @@ export default {
error: (category: string, message: string, ...args: unknown[]) => {
defaultLog('ERRO', category, message, ...args)
},
debug: (category: string, message: string, ...args: unknown[]) => {
if (process.env.DEBUG === 'true') {
defaultLog('DEBG', category, message, ...args)
}
}
}
function defaultLog(
+1 -1
View File
@@ -2,7 +2,7 @@ import { redirect } from '@remix-run/node'
import {
authorizationCodeGrantRequest,
calculatePKCECodeChallenge,
type Client,
Client,
discoveryRequest,
generateRandomCodeVerifier,
generateRandomNonce,
+1 -2
View File
@@ -27,9 +27,8 @@ export const {
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
sameSite: 'lax',
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
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
# I ONLY USE IT FOR DEVELOPING HEADPLANE
version: '3.9'
networks:
headplane-dev:
name: 'headplane-dev'
driver: 'bridge'
services:
headscale:
image: 'headscale/headscale:0.23.0-alpha5'
image: 'headscale/headscale:0.23.0-beta2'
container_name: 'headscale'
restart: 'unless-stopped'
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
ACLs when the file is changed.
> By default, the ACL file is read from `/etc/headscale/acl_policy.json`. This
can be overridden by setting the `ACL_FILE` environment variable and is also
overriden by the `acl_policy_path` key in the configuration file if set.
> By default, the ACL file is read from `/etc/headscale/acl_policy.json`.
> If `policy.path` is set and `policy.mode` is set to `file`, the ACL file will
> be read from the path specified in the configuration file instead.
## Deployment
Requirements:
- Headscale 0.23 alpha or later
- Headscale 0.23 or newer
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
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
Requirements:
- Headscale 0.23 alpha or later
- Headscale 0.23 or newer
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
Docker heavily simplifies the deployment process, but this process can be
@@ -29,7 +29,7 @@ Here is a simple Docker Compose deployment:
services:
headplane:
container_name: headplane
image: ghcr.io/tale/headplane:latest
image: ghcr.io/tale/headplane:0.3.0
restart: unless-stopped
ports:
- '3000:3000'
@@ -43,6 +43,7 @@ services:
OIDC_ISSUER: 'https://sso.example.com'
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
DISABLE_API_KEY_LOGIN: 'true'
COOKIE_SECURE: 'false'
# These are the default values
HOST: '0.0.0.0'
+2 -1
View File
@@ -9,11 +9,12 @@ You can configure Headplane using environment variables.
#### Optional Variables
- **`DEBUG`**: Enable debug logging (default: `false`).
- **`HOST`**: The host to bind the server to (default: `0.0.0.0`).
- **`PORT`**: The port to bind the server to (default: `3000`).
- **`CONFIG_FILE`**: The path to the Headscale `config.yaml` (default: `/etc/headscale/config.yaml`).
- **`ACL_FILE`**: The path to the ACL file (default: `/etc/headscale/acl_policy.json`, not needed if you have `acl_policy_path` in your config).
- **`HEADSCALE_CONFIG_UNSTRICT`**: This will disable the strict configuration loader (default: `false`).
- **`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
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
services:
headscale:
image: 'headscale/headscale:0.23.0-alpha12'
image: 'headscale/headscale:0.23.0'
container_name: 'headscale'
restart: 'unless-stopped'
command: 'serve'
@@ -50,7 +50,7 @@ services:
TZ: 'America/New_York'
headplane:
container_name: headplane
image: ghcr.io/tale/headplane:latest
image: ghcr.io/tale/headplane:0.3.0
restart: unless-stopped
volumes:
- './data:/var/lib/headscale'
@@ -68,6 +68,9 @@ services:
HOST: '0.0.0.0'
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
# If you want to share the same OIDC configuration you do not need this
OIDC_CLIENT_ID: 'headscale'
+6 -2
View File
@@ -88,7 +88,7 @@ spec:
serviceAccountName: default
containers:
- name: headplane
image: ghcr.io/tale/headplane:latest
image: ghcr.io/tale/headplane:0.3.0
env:
- name: COOKIE_SECRET
value: 'abcdefghijklmnopqrstuvwxyz'
@@ -98,12 +98,16 @@ spec:
valueFrom:
fieldRef:
fieldPath: metadata.name
# Only set this to false if you aren't behind a reverse proxy
- name: COOKIE_SECURE
value: 'false'
volumeMounts:
- name: headscale-config
mountPath: /etc/headscale
- name: headscale
image: headscale/headscale:0.23.0-alpha12
image: headscale/headscale:0.23.0
command: ['serve']
env:
- name: TZ
+1 -1
View File
@@ -51,7 +51,7 @@
"typescript": "^5.5.3",
"vite": "^5.3.3",
"vite-plugin-babel": "^1.2.0",
"vite-tsconfig-paths": "^4.2.1"
"vite-tsconfig-paths": "^4.3.2"
},
"overrides": {
"@types/react": "npm:types-react@beta",
+7 -12
View File
@@ -129,7 +129,7 @@ importers:
specifier: ^1.2.0
version: 1.2.0(@babel/core@7.24.7)(vite@5.3.3(@types/node@20.14.10))
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))
packages:
@@ -1889,11 +1889,8 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
caniuse-lite@1.0.30001600:
resolution: {integrity: sha512-+2S9/2JFhYmYaDpZvo0lKkfvuKIglrx68MwOBqMGHhQsNkLjB5xtc/TGoEPs+MxjSyN/72qer2g97nzR641mOQ==}
caniuse-lite@1.0.30001640:
resolution: {integrity: sha512-lA4VMpW0PSUrFnkmVuEKBUovSWKhj7puyCg8StBChgu298N1AtuF1sKWEvfDuimSEDbhlb/KqPKC3fs1HbuQUA==}
caniuse-lite@1.0.30001666:
resolution: {integrity: sha512-gD14ICmoV5ZZM1OdzPWmpx+q4GyefaK06zi8hmfHV5xe4/2nOQX3+Dw5o+fSqOws2xVwL9j+anOPFwHzdEdV4g==}
caseless@0.12.0:
resolution: {integrity: sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==}
@@ -6880,7 +6877,7 @@ snapshots:
autoprefixer@10.4.19(postcss@8.4.39):
dependencies:
browserslist: 4.23.0
caniuse-lite: 1.0.30001600
caniuse-lite: 1.0.30001666
fraction.js: 4.3.7
normalize-range: 0.1.2
picocolors: 1.0.0
@@ -6963,14 +6960,14 @@ snapshots:
browserslist@4.23.0:
dependencies:
caniuse-lite: 1.0.30001600
caniuse-lite: 1.0.30001666
electron-to-chromium: 1.4.716
node-releases: 2.0.14
update-browserslist-db: 1.0.13(browserslist@4.23.0)
browserslist@4.23.1:
dependencies:
caniuse-lite: 1.0.30001640
caniuse-lite: 1.0.30001666
electron-to-chromium: 1.4.818
node-releases: 2.0.14
update-browserslist-db: 1.1.0(browserslist@4.23.1)
@@ -7019,9 +7016,7 @@ snapshots:
camelcase-css@2.0.1: {}
caniuse-lite@1.0.30001600: {}
caniuse-lite@1.0.30001640: {}
caniuse-lite@1.0.30001666: {}
caseless@0.12.0: {}
+102 -39
View File
@@ -186,7 +186,8 @@ log:
# Path to a file containg ACL policies.
# ACLs can be defined as YAML or HUJSON.
# https://tailscale.com/kb/1018/acls/
acl_policy_path: /etc/headscale/acl.json
policy:
mode: 'database'
## DNS
#
@@ -197,7 +198,7 @@ acl_policy_path: /etc/headscale/acl.json
# - https://tailscale.com/kb/1081/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.
override_local_dns: true
@@ -253,6 +254,68 @@ dns_config:
# The FQDN of the hosts will be
# `hostname.user.base_domain` (e.g., _myhost.myuser.example.com_).
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
# Note: for production you will want to set this to something like:
@@ -268,46 +331,46 @@ oidc:
issuer: "https://sso.example.com"
client_id: "headscale"
client_secret: "super_secret_client_secret"
# # Alternatively, set `client_secret_path` to read the secret from the file.
# # It resolves environment variables, making integration to systemd's
# # `LoadCredential` straightforward:
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
# # client_secret and client_secret_path are mutually exclusive.
#
# # The amount of time from a node is authenticated with OpenID until it
# # expires and needs to reauthenticate.
# # Setting the value to "0" will mean no expiry.
# # Alternatively, set `client_secret_path` to read the secret from the file.
# # It resolves environment variables, making integration to systemd's
# # `LoadCredential` straightforward:
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
# # client_secret and client_secret_path are mutually exclusive.
#
# # The amount of time from a node is authenticated with OpenID until it
# # expires and needs to reauthenticate.
# # Setting the value to "0" will mean no expiry.
expiry: 180d
#
# # 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
# # only been enabled if you know what you are doing.
# # Note: enabling this will cause `oidc.expiry` to be ignored.
# use_expiry_from_token: false
#
# # 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".
#
# scope: ["openid", "profile", "email", "custom"]
# extra_params:
# domain_hint: example.com
#
# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
# # authentication request will be rejected.
#
#
# # 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
# # only been enabled if you know what you are doing.
# # Note: enabling this will cause `oidc.expiry` to be ignored.
# use_expiry_from_token: false
#
# # 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".
#
# scope: ["openid", "profile", "email", "custom"]
# extra_params:
# domain_hint: example.com
#
# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
# # authentication request will be rejected.
#
allowed_domains:
- example.com
# # Note: Groups from keycloak have a leading '/'
# allowed_groups:
# - /headscale
# allowed_users:
# - alice@example.com
#
# # 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`
# # 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`
#
# # Note: Groups from keycloak have a leading '/'
# allowed_groups:
# - /headscale
# allowed_users:
# - alice@example.com
#
# # 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`
# # 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`
#
strip_email_domain: true
# Logtail configuration