mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 16:18:57 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d041a62fcd | |||
| 0e6b5ea6d0 | |||
| a2054786f8 | |||
| 5a46fd0a97 | |||
| b8999161a2 | |||
| 224cbbdcaf | |||
| 75ba3a3dc7 |
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
? (
|
? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user