Compare commits

...

7 Commits

Author SHA1 Message Date
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
25 changed files with 443 additions and 169 deletions
+6
View File
@@ -1,3 +1,9 @@
### 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 -5
View File
@@ -102,11 +102,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>
) )
+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,
}} }}
/> />
)} )}
+203 -26
View File
@@ -1,32 +1,86 @@
/* 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 { loadAcl, loadContext, patchAcl } 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')!,
)
console.log(policy)
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,
isPolicyApi: true,
currentAcl: policy,
aclType: 'json',
} as const
} catch (error) {
if (!(error instanceof HeadscaleError)) {
throw error
}
if (error.status === 500) {
return {
hasAclWrite: false,
isPolicyApi: true,
currentAcl: policy,
aclType: 'json',
} as const
}
}
} catch (error) {
// Propagate our errors through normal error handling
if (!(error instanceof HeadscaleError)) {
throw error
}
// Not on 0.23-beta1 or later
if (error.status === 404) {
const { data, type, read, write } = await loadAcl()
return {
hasAclWrite: write,
isPolicyApi: false,
currentAcl: read ? data : '',
aclType: type,
}
}
throw error
} }
const { data, type } = await loadAcl()
return { return {
hasAclWrite: context.acl.write, hasAclWrite: true,
currentAcl: data, isPolicyApi: true,
aclType: type, currentAcl: '',
} aclType: 'json',
} as const
} }
export async function action({ request }: ActionFunctionArgs) { export async function action({ request }: ActionFunctionArgs) {
@@ -37,6 +91,21 @@ export async function action({ request }: ActionFunctionArgs) {
}) })
} }
const data = await request.json() as { acl: string, api: boolean }
if (data.api) {
try {
await put('v1/policy', session.get('hsApiKey')!, {
policy: data.acl,
})
return json({ success: true })
} catch (error) {
return json({ success: false }, {
status: error instanceof HeadscaleError ? error.status : 500,
})
}
}
const context = await loadContext() const context = await loadContext()
if (!context.acl.write) { if (!context.acl.write) {
return json({ success: false }, { return json({ success: false }, {
@@ -44,7 +113,6 @@ export async function action({ request }: ActionFunctionArgs) {
}) })
} }
const data = await request.json() as { acl: string }
await patchAcl(data.acl) await patchAcl(data.acl)
if (context.integration?.onAclChange) { if (context.integration?.onAclChange) {
@@ -54,10 +122,102 @@ export async function action({ request }: ActionFunctionArgs) {
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>
<p className="mb-2 text-md">
If you are running an older version of Headscale, the
{' '}
<Code>ACL_FILE</Code>
{' '}
environment variable is not set. Refer to the
{' '}
<Link
to="https://github.com/tale/headplane/blob/main/docs/Configuration.md"
name="Headplane Configuration"
>
Headplane Configuration
</Link>
{' '}
documentation for more information on how to set the
ACL file and integrate it with Headscale.
</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,10 +225,25 @@ export default function Page() {
? undefined ? undefined
: ( : (
<div className="mb-4"> <div className="mb-4">
<Notice> {data.isPolicyApi
The ACL policy file is readonly to Headplane. ? (
You will not be able to make changes here. <Notice className="w-fit">
</Notice> 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>
)
: (
<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 configure a Headplane integration
or make the ACL_FILE environment variable available.
</Notice>
)}
</div> </div>
)} )}
@@ -144,19 +319,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 +354,14 @@ 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,
api: data.isPolicyApi,
}, { }, {
method: 'PATCH', method: 'PATCH',
encType: 'application/json', encType: 'application/json',
}) })
toast('Updated tailnet ACL policy')
}} }}
> >
{fetcher.state === 'idle' {fetcher.state === 'idle'
@@ -197,7 +371,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'
+4 -32
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,14 +83,14 @@ 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}"`
submit({ submit({
[key]: nameservers [key]: nameservers
.filter((_, i) => i !== index), .filter((_, i) => i !== index),
+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
} }
} }
+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
} }
} }
+33 -4
View File
@@ -90,7 +90,12 @@ export async function loadContext(): Promise<HeadplaneContext> {
return context return context
} }
export async function loadAcl(): Promise<{ data: string, type: 'json' | 'yaml' }> { export async function loadAcl(): Promise<{
data: string
type: 'json' | 'yaml'
read: boolean
write: boolean
}> {
let path = process.env.ACL_FILE let path = process.env.ACL_FILE
if (!path) { if (!path) {
try { try {
@@ -100,18 +105,32 @@ export async function loadAcl(): Promise<{ data: string, type: 'json' | 'yaml' }
} }
if (!path) { if (!path) {
return { data: '', type: 'json' } throw new Error('No ACL file defined')
} }
// Check for attributes
let read = false
let write = false
try {
await access(path, constants.R_OK)
read = true
} catch {}
try {
await access(path, constants.W_OK)
write = true
} catch {}
const data = await readFile(path, 'utf8') const data = await readFile(path, 'utf8')
// Naive check for YAML over JSON // Naive check for YAML over JSON
// This is because JSON.parse doesn't support comments // This is because JSON.parse doesn't support comments
try { try {
parse(data) parse(data)
return { data, type: 'yaml' } return { data, type: 'yaml', read, write }
} catch { } catch {
return { data, type: 'json' } return { data, type: 'json', read, write }
} }
} }
@@ -205,6 +224,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
+20 -12
View File
@@ -53,6 +53,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 +89,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 +227,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',
+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
@@ -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-beta1'
container_name: 'headscale' container_name: 'headscale'
restart: 'unless-stopped' restart: 'unless-stopped'
command: 'serve' command: 'serve'
+1 -1
View File
@@ -61,7 +61,7 @@ overriden by the `acl_policy_path` key in the configuration file if set.
## 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:
+1 -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
+1 -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'
+1 -1
View File
@@ -103,7 +103,7 @@ spec:
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