Compare commits

...

14 Commits

Author SHA1 Message Date
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
32 changed files with 390 additions and 314 deletions
+12
View File
@@ -1,3 +1,15 @@
### 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>
) )
-30
View File
@@ -88,36 +88,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
-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
} }
-9
View File
@@ -177,15 +177,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
+1 -10
View File
@@ -70,14 +70,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,
}} }}
/> />
)} )}
+146 -36
View File
@@ -1,32 +1,64 @@
/* 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 { type 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 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 +69,101 @@ 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 } return json({ success: true })
await patchAcl(data.acl) } catch (error) {
return json({ success: false }, {
if (context.integration?.onAclChange) { status: error instanceof HeadscaleError ? error.status : 500,
await context.integration.onAclChange(context.integration.context) })
} }
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 +171,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 +253,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 +288,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 +304,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',
+1 -1
View File
@@ -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'
+6 -8
View File
@@ -24,13 +24,12 @@ 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.base_domain,
overrideLocal: config.dns_config.override_local_dns, nameservers: config.dns.nameservers.global,
nameservers: config.dns_config.nameservers, splitDns: config.dns.nameservers.split,
splitDns: config.dns_config.restricted_nameservers, searchDomains: config.dns.search_domains,
searchDomains: config.dns_config.domains, extraRecords: config.dns.extra_records,
extraRecords: config.dns_config.extra_records,
} }
return { return {
@@ -87,7 +86,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}
/> />
+2 -2
View File
@@ -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
} }
} }
+2 -2
View File
@@ -29,8 +29,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
} }
} }
-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'),
} }
+2 -2
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
} }
} }
+10 -78
View File
@@ -22,11 +22,6 @@ export interface HeadplaneContext {
write: boolean write: boolean
} }
acl: {
read: boolean
write: boolean
}
oidc?: { oidc?: {
issuer: string issuer: string
client: string client: string
@@ -69,7 +64,6 @@ export async function loadContext(): Promise<HeadplaneContext> {
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,56 +75,10 @@ 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) {
let config: HeadscaleConfig | undefined let config: HeadscaleConfig | undefined
try { try {
@@ -160,32 +108,6 @@ 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) {
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true' const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true'
const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY
@@ -205,6 +127,16 @@ 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
+25 -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({
@@ -220,11 +226,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',
@@ -301,6 +308,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)
} }
+18
View File
@@ -51,6 +51,24 @@ export async function post<T>(url: string, key: string, body?: unknown) {
return (response.json() as Promise<T>) 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
const response = await fetch(`${prefix}/api/${url}`, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
})
if (!response.ok) {
throw new HeadscaleError(await response.text(), response.status)
}
return (response.json() as Promise<T>)
}
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
+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 beta-2 or later
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc) - Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
Currently there are 3 integration providers that can do this for you: Currently there are 3 integration providers that can do this for you:
+2 -1
View File
@@ -16,7 +16,7 @@ Headplane in a production environment.
## Deployment ## Deployment
Requirements: Requirements:
- Headscale 0.23 alpha or later - Headscale 0.23 beta-2 or later
- 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
@@ -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'
+1 -1
View File
@@ -12,8 +12,8 @@ You can configure Headplane using environment variables.
- **`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.
+4 -1
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-beta2'
container_name: 'headscale' container_name: 'headscale'
restart: 'unless-stopped' restart: 'unless-stopped'
command: 'serve' command: 'serve'
@@ -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'
+5 -1
View File
@@ -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-beta2
command: ['serve'] command: ['serve']
env: env:
- name: TZ - name: TZ
+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