Compare commits

..

14 Commits

Author SHA1 Message Date
Aarnav Tale 361859f374 chore: v0.2.1 2024-07-07 14:57:33 -04:00
Aarnav Tale c60652e39f fix(TALE-4): prevent duplicate ACL tags from being added 2024-07-07 14:57:16 -04:00
Aarnav Tale 1db385e716 chore(TALE-11): remove unnecessary component in dns 2024-07-07 14:53:16 -04:00
Aarnav Tale ab0cb7b782 feat(TALE-11): support custom DNS records 2024-07-07 14:52:47 -04:00
Aarnav Tale fd73832879 fix: use acl diff editor and make discard button work 2024-07-07 14:28:28 -04:00
Aarnav Tale 7804d83181 feat(TALE-4): make acl tags editable from the menu 2024-07-07 14:20:39 -04:00
Aarnav Tale fe40a5734e chore: update deps 2024-07-07 14:19:53 -04:00
Aarnav Tale c0bdf91f6b chore: v0.2.0 2024-06-23 18:21:37 -04:00
Aarnav Tale bd46f97121 chore: update deps 2024-06-23 18:18:01 -04:00
Aarnav Tale d71e55d7af feat(TALE-10): edit machine owner via dropdown 2024-06-23 17:25:33 -04:00
Aarnav Tale ddd20fe027 feat: create select component 2024-06-23 17:25:22 -04:00
Aarnav Tale dca5f9d149 fix(TALE-6): make menu dropdown actions available on machines page 2024-06-23 01:05:03 -04:00
Aarnav Tale be8ce7a2fe fix: set defaults with HEADSCALE_UNSTRICT 2024-06-18 23:59:06 -04:00
Aarnav Tale 6fa0e265fe feat: update images 2024-06-03 23:33:38 -04:00
27 changed files with 2392 additions and 1518 deletions
+10
View File
@@ -1,3 +1,13 @@
### 0.2.1 (July 7, 2024)
- Added the ability to manage custom DNS records on your Tailnet.
- ACL tags for machines are now able to be changed via the machine menu.
- Fixed a bug where the ACL editor did not show the diffs correctly.
- Fixed an issue that stopped the "Discard changes" button in the ACL editor from working.
### 0.2.0 (June 23, 2024)
- Fix the dropdown options for machines not working on the machines page.
- Add an option to change the machine owner in the dropdown (aside from the users page).
### 0.1.9 (June 2, 2024)
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
- Utilize magic DNS hostnames for the machine overview page.
+14 -1
View File
@@ -1,7 +1,20 @@
# Headplane
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale)
![Preview](/assets/main-preview.png)
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/preview-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/preview-light.png"
>
<img
alt="Preview"
src="./assets/preview-dark.png"
>
</picture>
Headscale is a self-hosted version of the Tailscale control server, however, it currently lacks a first-party web UI.
Headplane aims to solve this issue by providing a GUI that can deeply integrate with the Headscale server.
+82
View File
@@ -0,0 +1,82 @@
import { ChevronDownIcon } from '@primer/octicons-react'
import { Dispatch, ReactNode, SetStateAction } from 'react'
import {
Button,
ListBox,
ListBoxItem,
Popover,
Select as AriaSelect,
SelectValue,
} from 'react-aria-components'
import { cn } from '~/utils/cn'
type SelectProps = Parameters<typeof AriaSelect>[0] & {
readonly label: string
readonly state?: [string, Dispatch<SetStateAction<string>>]
readonly children: ReactNode
}
function Select(props: SelectProps) {
return (
<AriaSelect
{...props}
aria-label={props.label}
selectedKey={props.state?.[0]}
onSelectionChange={(key) => {
props.state?.[1](key.toString())
}}
className={cn(
'block w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600',
'bg-white dark:bg-ui-800 dark:text-ui-300',
'focus-within:outline-6',
props.className,
)}
>
<Button className={cn(
'w-full flex items-center justify-between',
'px-2.5 py-1.5 rounded-lg',
)}
>
<SelectValue />
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
</Button>
<Popover
className={cn(
'mt-2 rounded-md w-[var(--trigger-width)]',
'bg-ui-100 dark:bg-ui-800 shadow-sm',
'overflow-hidden z-50',
'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95',
'exiting:fade-out exiting:zoom-out-95',
'fill-mode-forwards origin-left-right',
)}
>
<ListBox>
{props.children}
</ListBox>
</Popover>
</AriaSelect>
)
}
type ItemProps = Parameters<typeof ListBoxItem>[0]
function Item(props: ItemProps) {
return (
<ListBoxItem
{...props}
className={cn(
'px-4 py-2 w-full outline-none w-full',
'hover:bg-ui-200 dark:hover:bg-ui-700',
props.className,
)}
>
{props.children}
</ListBoxItem>
)
}
export default Object.assign(Select, { Item })
+2 -2
View File
@@ -152,7 +152,7 @@ export default function Page() {
</TabPanel>
<TabPanel id="diff">
<Monaco
variant="editor"
variant="diff"
language={data.aclType}
value={acl}
onChange={setAcl}
@@ -197,7 +197,7 @@ export default function Page() {
)}
Save
</Button>
<Button>
<Button onPress={() => { setAcl(data.currentAcl) }}>
Discard Changes
</Button>
</div>
+121
View File
@@ -0,0 +1,121 @@
import { Form, useSubmit } from '@remix-run/react'
import { useMemo, useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import { cn } from '~/utils/cn'
interface Props {
records: { name: string, type: 'A', value: string }[]
}
export default function AddDNS({ records }: Props) {
const submit = useSubmit()
const [name, setName] = useState('')
const [ip, setIp] = useState('')
const isDuplicate = useMemo(() => {
if (name.length === 0 || ip.length === 0) return false
const lookup = records.find(record => record.name === name)
if (!lookup) return false
return lookup.value === ip
}, [records, name, ip])
return (
<Dialog>
<Dialog.Button>
Add DNS record
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Add DNS record
</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<Form
method="POST"
onSubmit={(event) => {
event.preventDefault()
if (!name || !ip) return
setName('')
setIp('')
submit({
'dns_config.extra_records': [
...records,
{
name,
type: 'A',
value: ip,
},
],
}, {
method: 'PATCH',
encType: 'application/json',
})
close()
}}
>
<TextField
label="Domain"
placeholder="test.example.com"
name="domain"
state={[name, setName]}
className={cn(
'mt-2',
isDuplicate && 'outline outline-red-500',
)}
/>
<TextField
label="IP Address"
placeholder="101.101.101.101"
name="ip"
state={[ip, setIp]}
className={cn(
isDuplicate && 'outline outline-red-500',
)}
/>
{isDuplicate
? (
<p className="text-sm opacity-50">
A record with the domain name
{' '}
<Code>{name}</Code>
{' '}
and IP address
{' '}
<Code>{ip}</Code>
{' '}
already exists.
</p>
)
: undefined}
<div className="mt-6 flex justify-end gap-2 mt-8">
<Dialog.Action
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant="confirm"
onPress={close}
isDisabled={isDuplicate}
>
Add
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
+88
View File
@@ -0,0 +1,88 @@
import { useSubmit } from '@remix-run/react'
import { Button } from 'react-aria-components'
import Code from '~/components/Code'
import Link from '~/components/Link'
import TableList from '~/components/TableList'
import { cn } from '~/utils/cn'
import AddDNS from './dialogs/dns'
interface Props {
records: { name: string, type: 'A', value: string }[]
isDisabled: boolean
}
export default function DNS({ records, isDisabled }: Props) {
const submit = useSubmit()
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p className="text-gray-700 dark:text-gray-300">
Headscale supports adding custom DNS records to your Tailnet.
As of now, only
{' '}
<Code>A</Code>
{' '}
records are supported.
{' '}
<Link
to="https://headscale.net/dns-records/"
name="Headscale DNS Records documentation"
>
Learn More
</Link>
</p>
<div className="mt-4">
<TableList className="mb-8">
{records.length === 0
? (
<TableList.Item>
<p className="opacity-50 text-sm mx-auto">
No DNS records found
</p>
</TableList.Item>
)
: records.map((record, index) => (
<TableList.Item key={index}>
<div className="flex gap-24">
<div className="flex gap-2">
<p className="font-mono text-sm font-bold">{record.type}</p>
<p className="font-mono text-sm">{record.name}</p>
</div>
<p className="font-mono text-sm">{record.value}</p>
</div>
<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={() => {
submit({
'dns_config.extra_records': records
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
}}
>
Remove
</Button>
</TableList.Item>
))}
</TableList>
{isDisabled
? undefined
: (
<AddDNS records={records} />
)}
</div>
</div>
)
}
+6
View File
@@ -8,6 +8,7 @@ import { loadConfig, patchConfig } from '~/utils/config/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import DNS from './dns'
import Domains from './domains'
import MagicModal from './magic'
import Nameservers from './nameservers'
@@ -90,6 +91,11 @@ export default function Page() {
isDisabled={!data.config.write}
/>
<DNS
records={data.extraRecords}
isDisabled={!data.config.write}
/>
<Domains
baseDomain={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
+13 -6
View File
@@ -1,10 +1,11 @@
import { type LoaderFunctionArgs } from '@remix-run/node'
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node'
import { Link, useLoaderData } from '@remix-run/react'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { type Machine, Route } from '~/types'
import { type Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
@@ -12,6 +13,7 @@ import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import { menuAction } from './_data.machines._index/action'
import MenuOptions from './_data.machines._index/menu'
export async function loader({ request, params }: LoaderFunctionArgs) {
@@ -30,22 +32,26 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
}
}
const [machine, routes] = await Promise.all([
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const [machine, routes, users] = await Promise.all([
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
])
return {
machine: machine.node,
routes: routes.routes.filter(route => route.node.id === params.id),
users: users.users,
magic,
}
}
export async function action({ request }: ActionFunctionArgs) {
return menuAction(request)
}
export default function Page() {
const { machine, magic, routes } = useLoaderData<typeof loader>()
const { machine, magic, routes, users } = useLoaderData<typeof loader>()
useLiveData({ interval: 1000 })
const expired = machine.expiry === '0001-01-01 00:00:00'
@@ -87,6 +93,7 @@ export default function Page() {
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
/>
</div>
+106
View File
@@ -0,0 +1,106 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ActionFunctionArgs, json } from '@remix-run/node'
import { del, post } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
export async function menuAction(request: ActionFunctionArgs['request']) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401,
})
}
const data = await request.formData()
if (!data.has('_method') || !data.has('id')) {
return json({ message: 'No method or ID provided' }, {
status: 400,
})
}
const id = String(data.get('id'))
const method = String(data.get('_method'))
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' })
}
case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
return json({ message: 'Machine expired' })
}
case 'rename': {
if (!data.has('name')) {
return json({ message: 'No name provided' }, {
status: 400,
})
}
const name = String(data.get('name'))
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
return json({ message: 'Machine renamed' })
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, {
status: 400,
})
}
const route = String(data.get('route'))
const enabled = data.get('enabled') === 'true'
const postfix = enabled ? 'enable' : 'disable'
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
return json({ message: 'Route updated' })
}
case 'move': {
if (!data.has('to')) {
return json({ message: 'No destination provided' }, {
status: 400,
})
}
const to = String(data.get('to'))
try {
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
return json({ message: `Moved node ${id} to ${to}` })
} catch {
return json({ message: `Failed to move node ${id} to ${to}` }, {
status: 500,
})
}
}
case 'tags': {
const tags = data.get('tags')?.toString()
.split(',') ?? []
try {
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
tags,
})
return json({ message: 'Tags updated' })
} catch {
return json({ message: 'Failed to update tags' }, {
status: 500,
})
}
}
default: {
return json({ message: 'Invalid method' }, {
status: 400,
})
}
}
}
@@ -0,0 +1,119 @@
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import Select from '~/components/Select'
import { type Machine, User } from '~/types'
interface MoveProps {
readonly machine: Machine
readonly users: User[]
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly magic?: string
}
export default function Move({ machine, state, magic, users }: MoveProps) {
const [owner, setOwner] = useState(machine.user.name)
const submit = useSubmit()
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Change the owner of
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
When MagicDNS is enabled, the username of the owner
will control the hostname of the machine.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} />
<Select
label="Owner"
name="to"
placeholder="Select a user"
state={[owner, setOwner]}
>
{users.map(user => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
{magic
? (
owner === machine.user.name
? (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine is accessible by the hostname
{' '}
<Code className="text-sm">
{machine.givenName}
.
{owner}
.
{magic}
</Code>
.
</p>
)
: (
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
This machine will be accessible by the hostname
{' '}
<Code className="text-sm">
{machine.givenName}
.
{owner}
.
{magic}
</Code>
{'. '}
The hostname
{' '}
<Code className="text-sm">
{machine.givenName}
.
{machine.user.name}
.
{magic}
</Code>
{' '}
will no longer point to this machine.
</p>
)
)
: 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}
>
Change owner
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -0,0 +1,161 @@
import { PlusIcon, XIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import { Button, Input } from 'react-aria-components'
import Dialog from '~/components/Dialog'
import Link from '~/components/Link'
import { type Machine } from '~/types'
import { cn } from '~/utils/cn'
interface TagsProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
}
export default function Tags({ machine, state }: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags)
const [tag, setTag] = useState('')
const submit = useSubmit()
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit ACL tags for
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
ACL tags can be used to reference machines in your ACL policies.
See the
{' '}
<Link
to="https://tailscale.com/kb/1068/acl-tags"
name="Tailscale documentation"
>
Tailscale documentation
</Link>
{' '}
for more information.
</Dialog.Text>
<Form
method="POST"
onSubmit={(e) => {
submit(e.currentTarget)
}}
>
<input type="hidden" name="_method" value="tags" />
<input type="hidden" name="id" value={machine.id} />
<input type="hidden" name="tags" value={tags.join(',')} />
<div
className={cn(
'border border-ui-300 rounded-lg overflow-visible',
'dark:border-ui-700 dark:text-ui-300 mt-4',
)}
>
<div className="divide-y divide-ui-200 dark:divide-ui-600">
{tags.length === 0
? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center rounded-t-lg',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
No tags are set on this machine.
</p>
</div>
)
: tags.map(item => (
<div
key={item}
id={item}
className={cn(
'px-2.5 py-1.5 flex',
'items-center justify-between',
'font-mono text-sm',
)}
>
{item}
<Button
className="rounded-full p-0 w-6 h-6"
onPress={() => {
setTags(tags.filter(tag => tag !== item))
}}
>
<XIcon className="w-4 h-4" />
</Button>
</div>
))}
</div>
<div
className={cn(
'flex px-2.5 py-1.5 w-full',
'border-t border-ui-300 dark:border-ui-700',
'rounded-b-lg justify-between items-center',
'dark:bg-ui-800 dark:text-ui-300',
'focus-within:ring-2 focus-within:ring-blue-600',
tag.length > 0 && !tag.startsWith('tag:')
&& 'outline outline-red-500',
)}
>
<Input
placeholder="tag:example"
className={cn(
'bg-transparent w-full',
'border-none focus:ring-0',
'focus:outline-none font-mono text-sm',
'dark:bg-transparent dark:text-ui-300',
)}
value={tag}
onChange={(e) => {
setTag(e.currentTarget.value)
}}
/>
<Button
className={cn(
'rounded-lg p-0 h-6 w-6',
!tag.startsWith('tag:')
&& 'opacity-50 cursor-not-allowed',
)}
isDisabled={
tag.length === 0
|| !tag.startsWith('tag:')
|| tags.includes(tag)
}
onPress={() => {
setTags([...tags, tag])
setTag('')
}}
>
<PlusIcon className="w-4 h-4" />
</Button>
</div>
</div>
<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}
>
Save
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
+4 -2
View File
@@ -4,7 +4,7 @@ import { Link } from '@remix-run/react'
import Menu from '~/components/Menu'
import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { type Machine, type Route } from '~/types'
import { type Machine, type Route, User } from '~/types'
import { cn } from '~/utils/cn'
import MenuOptions from './menu'
@@ -12,10 +12,11 @@ import MenuOptions from './menu'
interface Props {
readonly machine: Machine
readonly routes: Route[]
readonly users: User[]
readonly magic?: string
}
export default function MachineRow({ machine, routes, magic }: Props) {
export default function MachineRow({ machine, routes, magic, users }: Props) {
const expired = machine.expiry === '0001-01-01 00:00:00'
|| machine.expiry === '0001-01-01T00:00:00Z'
? false
@@ -142,6 +143,7 @@ export default function MachineRow({ machine, routes, magic }: Props) {
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
/>
</td>
+22 -4
View File
@@ -2,25 +2,30 @@ import { KebabHorizontalIcon } from '@primer/octicons-react'
import { useState } from 'react'
import MenuComponent from '~/components/Menu'
import { Machine, Route } from '~/types'
import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn'
import Delete from './dialogs/delete'
import Expire from './dialogs/expire'
import Move from './dialogs/move'
import Rename from './dialogs/rename'
import Routes from './dialogs/routes'
import Tags from './dialogs/tags'
interface MenuProps {
machine: Machine
routes: Route[]
users: User[]
magic?: string
}
export default function Menu({ machine, routes, magic }: MenuProps) {
export default function Menu({ machine, routes, magic, users }: MenuProps) {
const renameState = useState(false)
const expireState = useState(false)
const removeState = useState(false)
const routesState = useState(false)
const moveState = useState(false)
const tagsState = useState(false)
const expired = machine.expiry === '0001-01-01 00:00:00'
|| machine.expiry === '0001-01-01T00:00:00Z'
@@ -51,6 +56,16 @@ export default function Menu({ machine, routes, magic }: MenuProps) {
routes={routes}
state={routesState}
/>
<Tags
machine={machine}
state={tagsState}
/>
<Move
machine={machine}
state={moveState}
users={users}
magic={magic}
/>
<MenuComponent>
<MenuComponent.Button
@@ -69,9 +84,12 @@ export default function Menu({ machine, routes, magic }: MenuProps) {
<MenuComponent.ItemButton control={routesState}>
Edit route settings
</MenuComponent.ItemButton>
<MenuComponent.Item className="opacity-50 hover:bg-transparent">
<MenuComponent.ItemButton control={tagsState}>
Edit ACL tags
</MenuComponent.Item>
</MenuComponent.ItemButton>
<MenuComponent.ItemButton control={moveState}>
Change owner
</MenuComponent.ItemButton>
{expired
? undefined
: (
+9 -66
View File
@@ -1,25 +1,27 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { InfoIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
import Code from '~/components/Code'
import { type Machine, type Route } from '~/types'
import { type Machine, type Route, User } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { del, post, pull } from '~/utils/headscale'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import { menuAction } from './action'
import MachineRow from './machine'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const [machines, routes] = await Promise.all([
const [machines, routes, users] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
])
const context = await loadContext()
@@ -35,73 +37,13 @@ export async function loader({ request }: LoaderFunctionArgs) {
return {
nodes: machines.nodes,
routes: routes.routes,
users: users.users,
magic,
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401,
})
}
const data = await request.formData()
if (!data.has('_method') || !data.has('id')) {
return json({ message: 'No method or ID provided' }, {
status: 400,
})
}
const id = String(data.get('id'))
const method = String(data.get('_method'))
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' })
}
case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
return json({ message: 'Machine expired' })
}
case 'rename': {
if (!data.has('name')) {
return json({ message: 'No name provided' }, {
status: 400,
})
}
const name = String(data.get('name'))
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
return json({ message: 'Machine renamed' })
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, {
status: 400,
})
}
const route = String(data.get('route'))
const enabled = data.get('enabled') === 'true'
const postfix = enabled ? 'enable' : 'disable'
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
return json({ message: 'Route updated' })
}
default: {
return json({ message: 'Invalid method' }, {
status: 400,
})
}
}
return menuAction(request)
}
export default function Page() {
@@ -156,6 +98,7 @@ export default function Page() {
key={machine.id}
machine={machine}
routes={data.routes.filter(route => route.node.id === machine.id)}
users={data.users}
magic={data.magic}
/>
))}
+46 -1
View File
@@ -183,7 +183,52 @@ export async function loadConfig(path?: string) {
configYaml = parseDocument(data)
if (process.env.HEADSCALE_CONFIG_UNSTRICT === 'true') {
config = configYaml.toJSON() as HeadscaleConfig
const loaded = configYaml.toJSON() as Record<string, unknown>
config = {
...loaded,
tls_letsencrypt_cache_dir: loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache',
tls_letsencrypt_challenge_type: loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01',
grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443',
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
randomize_client_port: loaded.randomize_client_port ?? false,
unix_socket: loaded.unix_socket ?? '/var/run/headscale/headscale.sock',
unix_socket_permission: loaded.unix_socket_permission ?? '0o770',
tuning: loaded.tuning ?? {
batch_change_delay: '800ms',
node_mapsession_buffered_chan_size: 30,
},
log: loaded.log ?? {
level: 'info',
format: 'text',
},
logtail: loaded.logtail ?? {
enabled: false,
},
cli: loaded.cli ?? {
timeout: '10s',
insecure: false,
},
prefixes: loaded.prefixes ?? {
allocation: 'sequential',
v4: '',
v6: '',
},
dns_config: loaded.dns_config ?? {
override_local_dns: false,
nameservers: [],
restricted_nameservers: {},
domains: [],
extra_records: [],
magic_dns: false,
base_domain: 'headscale.net',
},
} as HeadscaleConfig
console.log('Loaded Headscale configuration in non-strict mode')
console.log('By using this mode you forfeit GitHub issue support')
console.log('This is very dangerous and comes with a few caveats:')
Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+28 -2
View File
@@ -1,6 +1,19 @@
# Advanced Integration
![Integration Preview](/assets/advanced-preview.png)
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="../assets/integration-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="../assets/integration-light.png"
>
<img
alt="Integration Preview"
src="../assets/integration-dark.png"
>
</picture>
With the advanced integration it's possible to control Access Control Lists (ACLs) and the Headscale configuration via the Headplane UI.
Every single aspect of this integration is optional, meaning you can only use what you want.
@@ -18,7 +31,20 @@ By default this is set to `/etc/headscale/config.yaml`.
## Access Control Lists (ACLs)
![ACL Preview](/assets/acl-preview.png)
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="../assets/acls-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="../assets/acls-light.png"
>
<img
alt="ACL Preview"
src="../assets/acls-dark.png"
>
</picture>
Headplane will enable the `Access Controls` tab if it is able to read an ACL file from Headscale.<br>
The ACL file path is read from the following sources in order of priority:
+15 -15
View File
@@ -16,39 +16,39 @@
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@monaco-editor/react": "^4.6.0",
"@primer/octicons-react": "^19.9.0",
"@primer/octicons-react": "^19.10.0",
"@react-aria/toast": "3.0.0-beta.12",
"@react-stately/toast": "3.0.0-beta.4",
"@remix-run/node": "^2.9.2",
"@remix-run/react": "^2.9.2",
"@remix-run/serve": "^2.9.2",
"@remix-run/node": "^2.10.2",
"@remix-run/react": "^2.10.2",
"@remix-run/serve": "^2.10.2",
"clsx": "^2.1.1",
"isbot": "^5.1.8",
"oauth4webapi": "^2.10.4",
"react": "19.0.0-beta-26f2496093-20240514",
"isbot": "^5.1.11",
"oauth4webapi": "^2.11.1",
"react": "19.0.0-rc-f38c22b244-20240704",
"react-aria-components": "^1.2.1",
"react-dom": "19.0.0-beta-26f2496093-20240514",
"react-dom": "19.0.0-rc-f38c22b244-20240704",
"remix-utils": "^7.6.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-react-aria-components": "^1.1.3",
"undici": "^6.18.2",
"undici": "^6.19.2",
"usehooks-ts": "^3.1.0",
"yaml": "^2.4.3",
"yaml": "^2.4.5",
"zod": "^3.23.8"
},
"devDependencies": {
"@remix-run/dev": "^2.9.2",
"@remix-run/dev": "^2.10.2",
"@types/react": "npm:types-react@beta",
"@types/react-dom": "npm:types-react-dom@beta",
"autoprefixer": "^10.4.19",
"babel-plugin-react-compiler": "0.0.0-experimental-c23de8d-20240515",
"eslint": "^8.57.0",
"eslint-config-tale": "^2.0.4",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"postcss": "^8.4.39",
"tailwindcss": "^3.4.4",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.4.5",
"vite": "^5.2.12",
"typescript": "^5.5.3",
"vite": "^5.3.3",
"vite-plugin-babel": "^1.2.0",
"vite-tsconfig-paths": "^4.2.1"
},
+1546 -1419
View File
File diff suppressed because it is too large Load Diff