mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 00:08:14 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1a30185047 | |||
| c7e59b137c | |||
| 694b22f205 | |||
| 84855d9d51 | |||
| 6bea3b2a55 | |||
| 2ee1adf142 | |||
| 06d7d1ccad |
+1
-1
@@ -1,4 +1,4 @@
|
||||
API_KEY=abcdefghijklmnopqrstuvwxyz
|
||||
ROOT_API_KEY=abcdefghijklmnopqrstuvwxyz
|
||||
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
|
||||
DISABLE_API_KEY_LOGIN=true
|
||||
HEADSCALE_CONTAINER=headscale
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
### 0.1.5 (May 22, 2024)
|
||||
- Added experimental support for advanced integration without Docker.
|
||||
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
|
||||
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
|
||||
|
||||
### 0.1.5 (May 20, 2024)
|
||||
- Robust configuration handling with fallbacks based on the headscale source.
|
||||
- Support for `client_secret_path` on configuration file based OIDC.
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ReactNode } from 'react'
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Tooltip as AriaTooltip,
|
||||
TooltipTrigger,
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function Tooltip({ children }: Props) {
|
||||
return (
|
||||
<TooltipTrigger delay={0}>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function Button(props: Parameters<typeof AriaButton>[0]) {
|
||||
return (
|
||||
<AriaButton {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function Body({ children, className }: Props) {
|
||||
return (
|
||||
<AriaTooltip className={cn(
|
||||
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
||||
'bg-white dark:bg-ui-900 drop-shadow-sm',
|
||||
'border border-gray-200 dark:border-zinc-700',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</AriaTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Tooltip, { Button, Body })
|
||||
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* By default, Remix will handle hydrating your app on the client for you.
|
||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||
* For more information, see https://remix.run/file-conventions/entry.client
|
||||
*/
|
||||
|
||||
import { RemixBrowser } from '@remix-run/react'
|
||||
import { startTransition, StrictMode } from 'react'
|
||||
import { hydrateRoot } from 'react-dom/client'
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<RemixBrowser/>
|
||||
</StrictMode>
|
||||
)
|
||||
})
|
||||
@@ -1,137 +0,0 @@
|
||||
/**
|
||||
* By default, Remix will handle generating the HTTP Response for you.
|
||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||
* For more information, see https://remix.run/file-conventions/entry.server
|
||||
*/
|
||||
|
||||
import { PassThrough } from 'node:stream'
|
||||
|
||||
import type { EntryContext } from '@remix-run/node'
|
||||
import { createReadableStreamFromReadable } from '@remix-run/node'
|
||||
import { RemixServer } from '@remix-run/react'
|
||||
import { isbot } from 'isbot'
|
||||
import { renderToPipeableStream } from 'react-dom/server'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
const ABORT_DELAY = 5000
|
||||
|
||||
export default async function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) {
|
||||
return isbot(request.headers.get('user-agent') ?? '')
|
||||
? handleBotRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
)
|
||||
: handleBrowserRequest(
|
||||
request,
|
||||
responseStatusCode,
|
||||
responseHeaders,
|
||||
remixContext
|
||||
)
|
||||
}
|
||||
|
||||
async function handleBotRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={ABORT_DELAY}
|
||||
/>,
|
||||
{
|
||||
onAllReady() {
|
||||
shellRendered = true
|
||||
const body = new PassThrough()
|
||||
const stream = createReadableStreamFromReadable(body)
|
||||
|
||||
responseHeaders.set('Content-Type', 'text/html')
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode
|
||||
})
|
||||
)
|
||||
|
||||
pipe(body)
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error)
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
setTimeout(abort, ABORT_DELAY)
|
||||
})
|
||||
}
|
||||
|
||||
async function handleBrowserRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={ABORT_DELAY}
|
||||
/>,
|
||||
{
|
||||
onShellReady() {
|
||||
shellRendered = true
|
||||
const body = new PassThrough()
|
||||
const stream = createReadableStreamFromReadable(body)
|
||||
|
||||
responseHeaders.set('Content-Type', 'text/html')
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode
|
||||
})
|
||||
)
|
||||
|
||||
pipe(body)
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error)
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
setTimeout(abort, ABORT_DELAY)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { RepoForkedIcon } from '@primer/octicons-react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Switch from '~/components/Switch'
|
||||
import TextField from '~/components/TextField'
|
||||
import Tooltip from '~/components/Tooltip'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
nameservers: Record<string, string[]>
|
||||
}
|
||||
|
||||
export default function AddNameserver({ nameservers }: Props) {
|
||||
const submit = useSubmit()
|
||||
const [split, setSplit] = useState(false)
|
||||
const [ns, setNs] = useState('')
|
||||
const [domain, setDomain] = useState('')
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>
|
||||
Add nameserver
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Add nameserver
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className="font-semibold">
|
||||
Nameserver
|
||||
</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Use this IPv4 or IPv6 address to resolve names.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!ns) return
|
||||
|
||||
if (split) {
|
||||
const splitNs: Record<string, string[]> = {}
|
||||
for (const [key, value] of Object.entries(nameservers)) {
|
||||
if (key === 'global') continue
|
||||
splitNs[key] = value
|
||||
}
|
||||
|
||||
if (Object.keys(splitNs).includes(domain)) {
|
||||
splitNs[domain].push(ns)
|
||||
} else {
|
||||
splitNs[domain] = [ns]
|
||||
}
|
||||
|
||||
submit({
|
||||
'dns_config.restricted_nameservers': splitNs,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
} else {
|
||||
const globalNs = nameservers.global
|
||||
globalNs.push(ns)
|
||||
|
||||
submit({
|
||||
'dns_config.nameservers': globalNs,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}
|
||||
|
||||
setNs('')
|
||||
setDomain('')
|
||||
setSplit(false)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="DNS Server"
|
||||
placeholder="1.2.3.4"
|
||||
name="ns"
|
||||
state={[ns, setNs]}
|
||||
className="mt-2 mb-8"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="block">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Dialog.Text className="font-semibold">
|
||||
Restrict to domain
|
||||
</Dialog.Text>
|
||||
<Tooltip>
|
||||
<Tooltip.Button className={cn(
|
||||
'text-xs rounded-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
|
||||
Split DNS
|
||||
</Tooltip.Button>
|
||||
<Tooltip.Body>
|
||||
Only clients that support split DNS
|
||||
(Tailscale v1.8 or later for most platforms)
|
||||
will use this nameserver. Older clients
|
||||
will ignore it.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Dialog.Text className="text-sm">
|
||||
This nameserver will only be used for some domains.
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
label="Split DNS"
|
||||
defaultSelected={split}
|
||||
onChange={() => { setSplit(!split) }}
|
||||
/>
|
||||
</div>
|
||||
{split
|
||||
? (
|
||||
<>
|
||||
<Dialog.Text className="font-semibold mt-8">
|
||||
Domain
|
||||
</Dialog.Text>
|
||||
<TextField
|
||||
label="Domain"
|
||||
placeholder="example.com"
|
||||
name="domain"
|
||||
state={[domain, setDomain]}
|
||||
className="my-2"
|
||||
/>
|
||||
<Dialog.Text className="text-sm">
|
||||
Only single-label or fully-qualified queries
|
||||
matching this suffix should use the nameserver.
|
||||
</Dialog.Text>
|
||||
</>
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Add
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from 'react-aria-components'
|
||||
|
||||
import Link from '~/components/Link'
|
||||
import Switch from '~/components/Switch'
|
||||
import TableList from '~/components/TableList'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import AddNameserver from './dialogs/nameserver'
|
||||
|
||||
interface Props {
|
||||
nameservers: Record<string, string[]>
|
||||
override: boolean
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export default function Nameservers({ nameservers, override, isDisabled }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Set the nameservers used by devices on the Tailnet
|
||||
to resolve DNS queries.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1054/dns"
|
||||
name="Tailscale DNS Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
{Object.keys(nameservers).map(key => (
|
||||
<NameserverList
|
||||
key={key}
|
||||
isGlobal={key === 'global'}
|
||||
isDisabled={isDisabled}
|
||||
nameservers={nameservers[key]}
|
||||
override={override}
|
||||
name={key}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isDisabled
|
||||
? undefined
|
||||
: (
|
||||
<AddNameserver nameservers={nameservers} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ListProps {
|
||||
isGlobal: boolean
|
||||
isDisabled: boolean
|
||||
nameservers: string[]
|
||||
name: string
|
||||
override: boolean
|
||||
}
|
||||
|
||||
function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: ListProps) {
|
||||
const [localOverride, setLocalOverride] = useState(override)
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-md font-medium opacity-80">
|
||||
{isGlobal ? 'Global Nameservers' : name}
|
||||
</h2>
|
||||
{isGlobal
|
||||
? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-sm opacity-50">
|
||||
Override local DNS
|
||||
</span>
|
||||
<Switch
|
||||
label="Override local DNS"
|
||||
defaultSelected={localOverride}
|
||||
isDisabled={isDisabled}
|
||||
onChange={() => {
|
||||
submit({
|
||||
'dns_config.override_local_dns': !localOverride,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
|
||||
setLocalOverride(!localOverride)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: undefined}
|
||||
</div>
|
||||
<TableList>
|
||||
{nameservers.map((ns, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<TableList.Item key={index}>
|
||||
<p className="font-mono text-sm">{ns}</p>
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-red-600 dark:text-red-400',
|
||||
'hover:text-red-700 dark:hover:text-red-300',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => {
|
||||
if (isGlobal) {
|
||||
submit({
|
||||
'dns_config.nameservers': nameservers
|
||||
.filter((_, i) => i !== index),
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
} else {
|
||||
const key = `dns_config.restricted_nameservers."${name}"`
|
||||
submit({
|
||||
[key]: nameservers
|
||||
.filter((_, i) => i !== index),
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))}
|
||||
</TableList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,14 +1,8 @@
|
||||
import { type ActionFunctionArgs } from '@remix-run/node'
|
||||
import { json, useFetcher, useLoaderData } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { Button, Input } from 'react-aria-components'
|
||||
import { json, useLoaderData } from '@remix-run/react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Notice from '~/components/Notice'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import Switch from '~/components/Switch'
|
||||
import TableList from '~/components/TableList'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig, patchConfig } from '~/utils/config/headscale'
|
||||
import { restartHeadscale } from '~/utils/docker'
|
||||
@@ -17,6 +11,7 @@ import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import Domains from './domains'
|
||||
import MagicModal from './magic'
|
||||
import Nameservers from './nameservers'
|
||||
import RenameModal from './rename'
|
||||
|
||||
// We do not want to expose every config value
|
||||
@@ -68,9 +63,13 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
export default function Page() {
|
||||
useLiveData({ interval: 5000 })
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const fetcher = useFetcher()
|
||||
const [localOverride, setLocalOverride] = useState(data.overrideLocal)
|
||||
const [ns, setNs] = useState('')
|
||||
|
||||
const allNs: Record<string, string[]> = {}
|
||||
for (const key of Object.keys(data.splitDns)) {
|
||||
allNs[key] = data.splitDns[key]
|
||||
}
|
||||
|
||||
allNs.global = data.nameservers
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-16 max-w-screen-lg">
|
||||
@@ -82,113 +81,11 @@ export default function Page() {
|
||||
</Notice>
|
||||
)}
|
||||
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Set the nameservers used by devices on the Tailnet
|
||||
to resolve DNS queries.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-md font-medium opacity-80">
|
||||
Global Nameservers
|
||||
</h2>
|
||||
<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={!data.config.write}
|
||||
onChange={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns_config.override_local_dns': !localOverride,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
|
||||
setLocalOverride(!localOverride)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<TableList>
|
||||
{data.nameservers.map((ns, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<TableList.Item key={index}>
|
||||
<p className="font-mono text-sm">{ns}</p>
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-red-600 dark:text-red-400',
|
||||
'hover:text-red-700 dark:hover:text-red-300',
|
||||
!data.config.write && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={!data.config.write}
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns_config.nameservers': data.nameservers.filter((_, index_) => index_ !== index),
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))}
|
||||
{data.config.write
|
||||
? (
|
||||
<TableList.Item>
|
||||
<Input
|
||||
type="text"
|
||||
className="font-mono text-sm bg-transparent w-full mr-2"
|
||||
placeholder="Nameserver"
|
||||
value={ns}
|
||||
onChange={(event) => {
|
||||
setNs(event.target.value)
|
||||
}}
|
||||
/>
|
||||
{fetcher.state === 'idle'
|
||||
? (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm font-semibold',
|
||||
'text-blue-600 dark:text-blue-400',
|
||||
'hover:text-blue-700 dark:hover:text-blue-300',
|
||||
ns.length === 0 && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={ns.length === 0}
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns_config.nameservers': [...data.nameservers, ns],
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
|
||||
setNs('')
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Spinner className="w-3 h-3 mr-0" />
|
||||
)}
|
||||
</TableList.Item>
|
||||
)
|
||||
: undefined}
|
||||
</TableList>
|
||||
{/* TODO: Split DNS and Custom A Records */}
|
||||
</div>
|
||||
</div>
|
||||
<Nameservers
|
||||
nameservers={allNs}
|
||||
override={data.overrideLocal}
|
||||
isDisabled={!data.config.write}
|
||||
/>
|
||||
|
||||
<Domains
|
||||
baseDomain={data.magicDns ? data.baseDomain : undefined}
|
||||
|
||||
@@ -210,7 +210,7 @@ interface UserProps {
|
||||
|
||||
function Users({ users, magic }: UserProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4 auto-rows-min">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||
{users.map((user, i) => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
@@ -266,7 +266,7 @@ function InteractiveUsers({ users, setUsers, magic }: UserProps) {
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 auto-rows-min">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||
{users.map((user, i) => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
|
||||
+3
-10
@@ -27,11 +27,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
|
||||
// Only set if OIDC is properly enabled anyways
|
||||
if (context.oidc?.disableKeyLogin) {
|
||||
return startOidc(
|
||||
context.oidc.issuer,
|
||||
context.oidc.client,
|
||||
request,
|
||||
)
|
||||
return startOidc(context.oidc, request)
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -46,16 +42,13 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
|
||||
if (oidcStart) {
|
||||
const context = await loadContext()
|
||||
const issuer = context.oidc?.issuer
|
||||
const id = context.oidc?.client
|
||||
|
||||
if (!issuer || !id) {
|
||||
if (!context.oidc) {
|
||||
throw new Error('An invalid OIDC configuration was provided')
|
||||
}
|
||||
|
||||
// We know it exists here because this action only happens on OIDC
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
return startOidc(issuer, id, request)
|
||||
return startOidc(context.oidc, request)
|
||||
}
|
||||
|
||||
const apiKey = String(formData.get('api-key'))
|
||||
|
||||
@@ -9,10 +9,5 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
throw new Error('An invalid OIDC configuration was provided')
|
||||
}
|
||||
|
||||
return finishOidc(
|
||||
context.oidc.issuer,
|
||||
context.oidc.client,
|
||||
context.oidc.secret,
|
||||
request,
|
||||
)
|
||||
return finishOidc(context.oidc, request)
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ export interface HeadplaneContext {
|
||||
}
|
||||
|
||||
docker?: {
|
||||
sock: string
|
||||
url: string
|
||||
sock: boolean
|
||||
container: string
|
||||
}
|
||||
|
||||
@@ -163,19 +164,48 @@ async function checkAcl(config?: HeadscaleConfig) {
|
||||
}
|
||||
|
||||
async function checkDocker() {
|
||||
const path = process.env.DOCKER_SOCK ?? '/var/run/docker.sock'
|
||||
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
|
||||
|
||||
let url: URL | undefined
|
||||
try {
|
||||
await access(path, constants.R_OK)
|
||||
url = new URL(path)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
|
||||
// The API is available as an HTTP endpoint
|
||||
if (url.protocol === 'tcp:') {
|
||||
url.protocol = 'http:'
|
||||
}
|
||||
|
||||
// Check if the socket is accessible
|
||||
if (url.protocol === 'unix:') {
|
||||
try {
|
||||
await access(path, constants.R_OK)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (url.protocol === 'http:') {
|
||||
try {
|
||||
await fetch(new URL('/v1.30/version', url).href)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'unix:') {
|
||||
return
|
||||
}
|
||||
|
||||
if (!process.env.HEADSCALE_CONTAINER) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
sock: path,
|
||||
url: url.href,
|
||||
sock: url.protocol === 'unix:',
|
||||
container: process.env.HEADSCALE_CONTAINER,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -178,7 +178,35 @@ export async function patchConfig(partial: Record<string, unknown>) {
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(partial)) {
|
||||
configYaml.setIn(key.split('.'), value)
|
||||
// If the key is something like `test.bar."foo.bar"`, then we treat
|
||||
// the foo.bar as a single key, and not as two keys, so that needs
|
||||
// to be split correctly.
|
||||
|
||||
// Iterate through each character, and if we find a dot, we check if
|
||||
// the next character is a quote, and if it is, we skip until the next
|
||||
// quote, and then we skip the next character, which should be a dot.
|
||||
// If it's not a quote, we split it.
|
||||
const path = []
|
||||
let temp = ''
|
||||
let inQuote = false
|
||||
|
||||
for (const element of key) {
|
||||
if (element === '"') {
|
||||
inQuote = !inQuote
|
||||
}
|
||||
|
||||
if (element === '.' && !inQuote) {
|
||||
path.push(temp.replaceAll('"', ''))
|
||||
temp = ''
|
||||
continue
|
||||
}
|
||||
|
||||
temp += element
|
||||
}
|
||||
|
||||
// Push the remaining element
|
||||
path.push(temp.replaceAll('"', ''))
|
||||
configYaml.setIn(path, value)
|
||||
}
|
||||
|
||||
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
|
||||
|
||||
+16
-11
@@ -1,5 +1,3 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
/* eslint-disable no-constant-condition */
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
import { Client } from 'undici'
|
||||
@@ -13,9 +11,12 @@ export async function sighupHeadscale() {
|
||||
return
|
||||
}
|
||||
|
||||
const client = new Client('http://localhost', {
|
||||
socketPath: context.docker.sock,
|
||||
})
|
||||
// Supports the DOCKER_SOCK environment variable
|
||||
const client = context.docker.sock
|
||||
? new Client('http://localhost', {
|
||||
socketPath: context.docker.url,
|
||||
})
|
||||
: new Client(context.docker.url)
|
||||
|
||||
const response = await client.request({
|
||||
method: 'POST',
|
||||
@@ -33,9 +34,12 @@ export async function restartHeadscale() {
|
||||
return
|
||||
}
|
||||
|
||||
const client = new Client('http://localhost', {
|
||||
socketPath: context.docker.sock,
|
||||
})
|
||||
// Supports the DOCKER_SOCK environment variable
|
||||
const client = context.docker.sock
|
||||
? new Client('http://localhost', {
|
||||
socketPath: context.docker.url,
|
||||
})
|
||||
: new Client(context.docker.url)
|
||||
|
||||
const response = await client.request({
|
||||
method: 'POST',
|
||||
@@ -48,14 +52,15 @@ export async function restartHeadscale() {
|
||||
|
||||
// Wait for Headscale to restart before continuing
|
||||
let attempts = 0
|
||||
// eslint-disable-next-line
|
||||
while (true) {
|
||||
try {
|
||||
// Acceptable blank because API_KEY is not required
|
||||
await pull('v1/apikey', process.env.API_KEY ?? '')
|
||||
// Acceptable blank because ROOT_API_KEY is not required
|
||||
await pull('v1/apikey', context.oidc?.rootKey ?? '')
|
||||
return
|
||||
} catch (error) {
|
||||
// This means the server is up but the API key is invalid
|
||||
// This can happen if the user only uses API_KEY via cookies
|
||||
// This can happen if the user only uses ROOT_API_KEY via cookies
|
||||
if (error instanceof HeadscaleError && error.status === 401) {
|
||||
break
|
||||
}
|
||||
|
||||
+65
-40
@@ -1,36 +1,42 @@
|
||||
import { redirect } from '@remix-run/node'
|
||||
import {
|
||||
authorizationCodeGrantRequest,
|
||||
calculatePKCECodeChallenge, type Client,
|
||||
calculatePKCECodeChallenge,
|
||||
type Client,
|
||||
discoveryRequest,
|
||||
generateRandomCodeVerifier,
|
||||
generateRandomNonce,
|
||||
generateRandomState,
|
||||
getValidatedIdTokenClaims, isOAuth2Error,
|
||||
getValidatedIdTokenClaims,
|
||||
isOAuth2Error,
|
||||
parseWwwAuthenticateChallenges,
|
||||
processAuthorizationCodeOpenIDResponse,
|
||||
processDiscoveryResponse,
|
||||
validateAuthResponse } from 'oauth4webapi'
|
||||
validateAuthResponse,
|
||||
} from 'oauth4webapi'
|
||||
|
||||
import { post } from '~/utils/headscale'
|
||||
import { commitSession, getSession } from '~/utils/sessions'
|
||||
|
||||
export async function startOidc(issuer: string, client: string, request: Request) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
import { HeadplaneContext } from './config/headplane'
|
||||
|
||||
type OidcConfig = NonNullable<HeadplaneContext['oidc']>
|
||||
|
||||
export async function startOidc(oidc: OidcConfig, req: Request) {
|
||||
const session = await getSession(req.headers.get('Cookie'))
|
||||
if (session.has('hsApiKey')) {
|
||||
return redirect('/', {
|
||||
status: 302,
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Set-Cookie': await commitSession(session)
|
||||
}
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const issuerUrl = new URL(issuer)
|
||||
const issuerUrl = new URL(oidc.issuer)
|
||||
const oidcClient = {
|
||||
client_id: client,
|
||||
token_endpoint_auth_method: 'client_secret_basic'
|
||||
client_id: oidc.client,
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
} satisfies Client
|
||||
|
||||
const response = await discoveryRequest(issuerUrl)
|
||||
@@ -44,9 +50,9 @@ export async function startOidc(issuer: string, client: string, request: Request
|
||||
const verifier = generateRandomCodeVerifier()
|
||||
const challenge = await calculatePKCECodeChallenge(verifier)
|
||||
|
||||
const callback = new URL('/admin/oidc/callback', request.url)
|
||||
callback.protocol = request.url.includes('localhost') ? 'http:' : 'https:'
|
||||
callback.hostname = request.headers.get('Host') ?? ''
|
||||
const callback = new URL('/admin/oidc/callback', req.url)
|
||||
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
|
||||
callback.hostname = req.headers.get('Host') ?? ''
|
||||
const authUrl = new URL(processed.authorization_endpoint)
|
||||
|
||||
authUrl.searchParams.set('client_id', oidcClient.client_id)
|
||||
@@ -65,29 +71,27 @@ export async function startOidc(issuer: string, client: string, request: Request
|
||||
return redirect(authUrl.href, {
|
||||
status: 302,
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Set-Cookie': await commitSession(session)
|
||||
}
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function finishOidc(issuer: string, client: string, secret: string, request: Request) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
export async function finishOidc(oidc: OidcConfig, req: Request) {
|
||||
const session = await getSession(req.headers.get('Cookie'))
|
||||
if (session.has('hsApiKey')) {
|
||||
return redirect('/', {
|
||||
status: 302,
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Set-Cookie': await commitSession(session)
|
||||
}
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const issuerUrl = new URL(issuer)
|
||||
const issuerUrl = new URL(oidc.issuer)
|
||||
const oidcClient = {
|
||||
client_id: client,
|
||||
client_secret: secret,
|
||||
token_endpoint_auth_method: 'client_secret_basic'
|
||||
client_id: oidc.client,
|
||||
client_secret: oidc.secret,
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
} satisfies Client
|
||||
|
||||
const response = await discoveryRequest(issuerUrl)
|
||||
@@ -103,22 +107,41 @@ export async function finishOidc(issuer: string, client: string, secret: string,
|
||||
throw new Error('No OIDC state found in the session')
|
||||
}
|
||||
|
||||
const parameters = validateAuthResponse(processed, oidcClient, new URL(request.url), state)
|
||||
const parameters = validateAuthResponse(
|
||||
processed,
|
||||
oidcClient,
|
||||
new URL(req.url),
|
||||
state,
|
||||
)
|
||||
|
||||
if (isOAuth2Error(parameters)) {
|
||||
throw new Error('Invalid response from the OIDC provider')
|
||||
}
|
||||
|
||||
const callback = new URL('/admin/oidc/callback', request.url)
|
||||
callback.protocol = request.url.includes('localhost') ? 'http:' : 'https:'
|
||||
callback.hostname = request.headers.get('Host') ?? ''
|
||||
const callback = new URL('/admin/oidc/callback', req.url)
|
||||
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
|
||||
callback.hostname = req.headers.get('Host') ?? ''
|
||||
|
||||
const tokenResponse = await authorizationCodeGrantRequest(
|
||||
processed,
|
||||
oidcClient,
|
||||
parameters,
|
||||
callback.href,
|
||||
verifier,
|
||||
)
|
||||
|
||||
const tokenResponse = await authorizationCodeGrantRequest(processed, oidcClient, parameters, callback.href, verifier)
|
||||
const challenges = parseWwwAuthenticateChallenges(tokenResponse)
|
||||
if (challenges) {
|
||||
throw new Error('Recieved a challenge from the OIDC provider')
|
||||
}
|
||||
|
||||
const result = await processAuthorizationCodeOpenIDResponse(processed, oidcClient, tokenResponse, nonce)
|
||||
const result = await processAuthorizationCodeOpenIDResponse(
|
||||
processed,
|
||||
oidcClient,
|
||||
tokenResponse,
|
||||
nonce,
|
||||
)
|
||||
|
||||
if (isOAuth2Error(result)) {
|
||||
throw new Error('Invalid response from the OIDC provider')
|
||||
}
|
||||
@@ -126,21 +149,23 @@ export async function finishOidc(issuer: string, client: string, secret: string,
|
||||
const claims = getValidatedIdTokenClaims(result)
|
||||
const expDate = new Date(claims.exp * 1000).toISOString()
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const keyResponse = await post<{ apiKey: string }>('v1/apikey', process.env.API_KEY!, {
|
||||
expiration: expDate
|
||||
})
|
||||
const keyResponse = await post<{ apiKey: string }>(
|
||||
'v1/apikey',
|
||||
oidc.rootKey,
|
||||
{
|
||||
expiration: expDate,
|
||||
},
|
||||
)
|
||||
|
||||
session.set('hsApiKey', keyResponse.apiKey)
|
||||
session.set('user', {
|
||||
name: claims.name ? String(claims.name) : 'Anonymous',
|
||||
email: claims.email ? String(claims.email) : undefined
|
||||
email: claims.email ? String(claims.email) : undefined,
|
||||
})
|
||||
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Set-Cookie': await commitSession(session)
|
||||
}
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -30,10 +30,14 @@ The ACL file path is read from the following sources in order of priority:
|
||||
|
||||
The Docker integration can be used to automatically reload the configuration or ACLs when they are changed.
|
||||
In order for this to work, you'll need to pass in the `HEADSCALE_CONTAINER` environment variable.
|
||||
You'll also need to ensure that `/var/run/docker.sock` is mounted if Headplane is running in a container.
|
||||
This should be either the name or ID of the Headscale container (you can retrieve this using `docker ps`).
|
||||
If the other integrations aren't setup, then Headplane will automatically disable the Docker integration.
|
||||
|
||||
By default the integration will check for `/var/run/docker.sock`, however you can override this by
|
||||
setting the `DOCKER_SOCK` environment variable if you use a different configuration than the default.
|
||||
When setting `DOCKER_SOCK`, you'll need to include the protocol (e.g., `unix://` or `tcp://`).
|
||||
Headplane currently does not support the HTTPS protocol for the Docker socket.
|
||||
|
||||
## Deployment
|
||||
|
||||
Requirements:
|
||||
@@ -85,7 +89,7 @@ services:
|
||||
# This NEEDS to be set with OIDC, regardless of what's in the config
|
||||
# This needs to be a very long-lived (999 day) API key used to create
|
||||
# shorter ones for OIDC and allow the OIDC functionality to work
|
||||
API_KEY: 'abcdefghijklmnopqrstuvwxyz'
|
||||
ROOT_API_KEY: 'abcdefghijklmnopqrstuvwxyz'
|
||||
```
|
||||
|
||||
> For a breakdown of each configuration variable, please refer to the [Configuration](/docs/Configuration.md) guide.
|
||||
|
||||
@@ -31,8 +31,7 @@ services:
|
||||
COOKIE_SECRET: 'abcdefghijklmnopqrstuvwxyz'
|
||||
|
||||
# These are all optional!
|
||||
HEADSCALE_CONTAINER: 'headscale'
|
||||
API_KEY: 'abcdefghijklmnopqrstuvwxyz'
|
||||
ROOT_API_KEY: 'abcdefghijklmnopqrstuvwxyz'
|
||||
OIDC_CLIENT_ID: 'headscale'
|
||||
OIDC_ISSUER: 'https://sso.example.com'
|
||||
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
|
||||
|
||||
@@ -13,6 +13,14 @@ You can configure Headplane using environment variables.
|
||||
- **`PORT`**: The port to bind the server to (default: `3000`).
|
||||
- **`CONFIG_FILE`**: The path to the Headscale `config.yaml` (default: `/etc/headscale/config.yaml`).
|
||||
- **`ACL_FILE`**: The path to the ACL file (default: `/etc/headscale/acl_policy.json`, not needed if you have `acl_policy_path` in your config).
|
||||
|
||||
#### Docker Integration
|
||||
The Docker integration allows Headplane to manage the Headscale docker container.
|
||||
You'll need to provide these variables if you want to use this feature.
|
||||
Keep in mind that `DOCKER_SOCK` must start with a protocol (e.g., `unix://`).
|
||||
Secure API is currently not supported.
|
||||
|
||||
- **`DOCKER_SOCK`**: The protocol and path to the Docker socket (default: `unix:///var/run/docker.sock`).
|
||||
- **`HEADSCALE_CONTAINER`**: The name of the Headscale container (required for Docker integration).
|
||||
|
||||
### SSO/OpenID Connect
|
||||
@@ -23,7 +31,7 @@ If you use the Headscale configuration integration, these are not required.
|
||||
- **`OIDC_ISSUER`**: The issuer URL of your OIDC provider.
|
||||
- **`OIDC_CLIENT_ID`**: The client ID of your OIDC provider.
|
||||
- **`OIDC_CLIENT_SECRET`**: The client secret of your OIDC provider.
|
||||
- **`API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long).
|
||||
- **`ROOT_API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long).
|
||||
- **`DISABLE_API_KEY_LOGIN`**: If you want to disable API key login, set this to `true`.
|
||||
|
||||
Here's what an example with Authelia would look like if you used the same client for both Headscale and Headplane.
|
||||
|
||||
+2
-1
@@ -61,7 +61,8 @@
|
||||
"@types/react-dom": "npm:types-react-dom@beta"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
"node": ">=20",
|
||||
"pnpm": ">=9 <10"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
|
||||
Generated
+5475
-4285
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user