Compare commits

...

28 Commits

Author SHA1 Message Date
Aarnav Tale 61ae161496 chore: update readme 2024-05-01 02:51:03 -04:00
Aarnav Tale 82f5e17207 feat: add various machine operations 2024-05-01 02:47:45 -04:00
Aarnav Tale a63f4e4d52 fix: API_KEY is no longer required for login to work 2024-05-01 02:47:36 -04:00
Aarnav Tale a57e777a6b feat: completely nuke headlessui and consolidate to aria 2024-05-01 02:21:54 -04:00
Aarnav Tale 8205f2b99b feat: replace the old modal completely 2024-04-30 11:03:53 -04:00
Aarnav Tale c95218b8dd fix: prune an image for readme 2024-04-29 20:33:51 -04:00
Aarnav Tale 769556efda fix: make the toaster click-throughable 2024-04-29 20:32:14 -04:00
Aarnav Tale ea47bb5fec chore: cleanup and use aria components in places 2024-04-29 20:32:00 -04:00
Aarnav Tale 3abd617a90 feat: switch to react aria and replace many components 2024-04-29 20:31:29 -04:00
Aarnav Tale cbecf85979 chore: update readme images 2024-04-23 01:15:31 -04:00
Aarnav Tale 0ebadc26bc fix: oidc will fail in the dev config 2024-04-22 14:34:03 -04:00
Aarnav Tale 252e78d618 feat: facelift overall machines page and refactor 2024-04-22 14:33:51 -04:00
Aarnav Tale c6930732ee fix: oidc config variables were named incorrectly 2024-04-22 13:21:48 -04:00
Aarnav Tale f04b17109b fix: only sighup if we have docker 2024-04-17 17:44:12 -04:00
Aarnav Tale 0ff9e6fdc3 feat: implement better ssr fallbacks 2024-04-17 17:42:44 -04:00
Aarnav Tale 94174ebcce feat: add dumb yaml detection 2024-04-17 17:20:35 -04:00
Aarnav Tale c2fe69ec17 chore: add acl file instructions 2024-04-15 04:06:18 -04:00
Aarnav Tale b285753b24 fix: omg why do i forget these things here 2024-04-15 04:02:23 -04:00
Aarnav Tale 8eac733a5d fix: we shouldn't return early if the env var isn't there 2024-04-15 03:52:51 -04:00
Aarnav Tale 89a7cb5aae chore: stop prefixing tags with v 2024-04-15 03:41:28 -04:00
Aarnav Tale 39868b5043 feat: implement raw acl editing on the web ui 2024-04-15 03:40:52 -04:00
Aarnav Tale ca32590e54 fix: string constructor in js causes oidc fast track 2024-04-07 14:01:38 -04:00
Aarnav Tale a846249be1 chore: add a warning to the compose.yaml 2024-04-05 18:32:12 -04:00
Aarnav Tale bcf00beb75 fix: add publish archs 2024-04-02 15:08:47 -04:00
Aarnav Tale 6dae5d647a chore: various fixes 2024-04-01 19:41:39 -04:00
Aarnav Tale d787b8517e fix: protect the restart endpoint with auth 2024-04-01 19:33:22 -04:00
Aarnav Tale bdb00b6cd7 feat: make the modal and dropdown more workable 2024-03-31 19:06:06 -04:00
Aarnav Tale f1347803a4 fix: logout is a button 2024-03-31 18:14:28 -04:00
43 changed files with 3581 additions and 782 deletions
+2 -1
View File
@@ -2,7 +2,7 @@ name: Publish Docker Image
on:
push:
tags:
- 'v*'
- '*'
jobs:
publish:
@@ -39,3 +39,4 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64
+9
View File
@@ -0,0 +1,9 @@
### 0.1.2 (May 1, 2024)
- Added support for renaming, expiring, removing, and managing the routes of a machine.
- Implemented an expiry check for machines which now reflect on the machine table.
- Fixed an issue where `HEADSCALE_CONTAINER` was needed to start even without the Docker integration.
- Removed the requirement for the root `API_KEY` unless OIDC was being used for authentication.
- Switched to [React Aria](https://react-spectrum.adobe.com/react-aria/) for better accessibility support.
- Cleaned up various different UI inconsistencies and copied components that could've been abstracted.
- Added a changelog for any new versions going forward.
+7 -7
View File
@@ -4,18 +4,18 @@
![Preview](/assets/main-preview.png)
Headscale is a self-hosted version of the Tailscale control server, however, it currently lacks a first-party web UI.
This is a relatively tiny Remix app that aims to provide a usable GUI for the Headscale server.
It's still very early in it's development, however these are some of the features that are planned.
Headplane aims to solve this issue by providing a GUI that can deeply integrate with the Headscale server.
It's able to replicate nearly all of the functions of the official Tailscale SaaS UI, including:
- [ ] Editable tags, machine names, users, etc
- [ ] ACL control through Docker integration
- [x] OIDC based login for the web UI
- [x] Automated API key regeneration
- [x] Editable headscale configuration
- Machine/Node expiry, network routing, name, and owner management
- Access Control List (ACL) and tagging configuration
- Support for OpenID Connect (OIDC) as a login provider
- DNS and *safe* Headscale configuration management
## Deployment
- If you run Headscale in a Docker container, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
- If you run Headscale natively, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
- For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
## Contributing
If you would like to contribute, please install a relatively modern version of Node.js and PNPM.
+2 -1
View File
@@ -1,5 +1,6 @@
import { ClipboardIcon } from '@heroicons/react/24/outline'
import toast from 'react-hot-toast/headless'
import { toast } from './Toaster'
type Properties = {
readonly name: string;
+142
View File
@@ -0,0 +1,142 @@
/* eslint-disable unicorn/no-keyword-prefix */
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import {
Button as AriaButton,
Dialog as AriaDialog,
DialogTrigger,
Heading as AriaHeading,
Modal,
ModalOverlay
} from 'react-aria-components'
import { cn } from '~/utils/cn'
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
function Button(properties: ButtonProperties) {
return (
<AriaButton
{...properties}
aria-label='Dialog'
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
)}
// If control is passed, set the state value
onPress={properties.control ? () => {
properties.control?.[1](true)
} : undefined}
/>
)
}
type ActionProperties = Parameters<typeof AriaButton>[0] & {
readonly variant: 'cancel' | 'confirm';
}
function Action(properties: ActionProperties) {
return (
<AriaButton
{...properties}
type={properties.variant === 'confirm' ? 'submit' : 'button'}
className={cn(
'px-4 py-2 rounded-lg',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.variant === 'cancel'
? 'text-ui-700 dark:text-ui-300'
: 'text-ui-300 dark:text-ui-300',
properties.variant === 'confirm'
? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800'
: 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
properties.className
)}
/>
)
}
function Title(properties: Parameters<typeof AriaHeading>[0]) {
return (
<AriaHeading
{...properties}
slot='title'
className={cn(
'text-lg font-semibold leading-6 mb-5',
properties.className
)}
/>
)
}
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
return (
<p
{...properties}
className={cn(
'text-base leading-6 my-0',
properties.className
)}
/>
)
}
type PanelProperties = {
readonly children: (close: () => void) => ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
readonly className?: string;
}
function Panel({ children, control, className }: PanelProperties) {
return (
<ModalOverlay
aria-hidden='true'
className={cn(
'fixed inset-0 h-screen w-screen z-50 bg-black/30',
'flex items-center justify-center dark:bg-black/70',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:duration-200 entering:ease-out',
'exiting:fade-out exiting:duration-100 exiting:ease-in',
className
)}
isOpen={control ? control[0] : undefined}
onOpenChange={control ? control[1] : undefined}
>
<Modal
className={cn(
'w-full max-w-md overflow-hidden rounded-xl p-4',
'bg-ui-50 dark:bg-ui-900 shadow-lg',
'entering:animate-in exiting:animate-out',
'dark:border dark:border-ui-700',
'entering:zoom-in-95 entering:ease-out entering:duration-200',
'exiting:zoom-out-95 exiting:ease-in exiting:duration-100'
)}
>
<AriaDialog role='alertdialog' className='outline-none relative'>
{({ close }) => children(close)}
</AriaDialog>
</Modal>
</ModalOverlay>
)
}
type DialogProperties = {
readonly children: ReactNode;
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
function Dialog({ children, control }: DialogProperties) {
if (control) {
return children
}
return (
<DialogTrigger>
{children}
</DialogTrigger>
)
}
export default Object.assign(Dialog, { Button, Title, Text, Panel, Action })
-84
View File
@@ -1,84 +0,0 @@
import { Menu, type MenuButtonProps, Transition } from '@headlessui/react'
import clsx from 'clsx'
import { Fragment, type HTMLProps, type ReactNode } from 'react'
type Properties = {
readonly children: ReactNode;
readonly button: ReactNode;
// eslint-disable-next-line unicorn/no-keyword-prefix
readonly className?: string;
}
function Dropdown(properties: Properties) {
return (
<div className={clsx('relative', properties.className)}>
<Menu>
<Button className='flex flex-col items-center'>
{properties.button}
</Button>
<Transition
as={Fragment}
enter='transition ease-out duration-100'
enterFrom='transform opacity-0 scale-95'
enterTo='transform opacity-100 scale-100'
leave='transition ease-in duration-75'
leaveFrom='transform opacity-100 scale-100'
leaveTo='transform opacity-0 scale-95'
>
<Menu.Items className={clsx(
'absolute right-0 w-fit max-w-36 mt-2 rounded-md',
'text-gray-700 dark:text-gray-300',
'bg-white dark:bg-zinc-800 text-right',
'overflow-hidden z-50',
'border border-gray-200 dark:border-zinc-700',
'divide-y divide-gray-200 dark:divide-zinc-700'
)}
>
{properties.children}
</Menu.Items>
</Transition>
</Menu>
</div>
)
}
function Button(properties: MenuButtonProps<'button'>) {
return (
<Menu.Button
{...properties}
className={clsx(
properties.className
)}
>
{properties.children}
</Menu.Button>
)
}
type ItemProperties = HTMLProps<HTMLDivElement> & {
variant?: 'static' | 'normal';
}
function Item(properties: ItemProperties) {
return (
<Menu.Item>
{({ active }) => (
<div
{...properties}
className={clsx(
'px-4 py-2 w-full text-right',
'focus:outline-none focus:ring',
'focus:ring-gray-300 dark:focus:ring-zinc-700',
properties.className,
properties.variant !== 'static' && active
? 'bg-gray-100 dark:bg-zinc-500' : ''
)}
>
{properties.children}
</div>
)}
</Menu.Item>
)
}
export default Object.assign(Dropdown, { Item })
+34 -48
View File
@@ -1,66 +1,52 @@
import { Transition } from '@headlessui/react'
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
import { isRouteErrorResponse, useRouteError } from '@remix-run/react'
import clsx from 'clsx'
import { Fragment, useEffect, useState } from 'react'
import { useState } from 'react'
import { cn } from '~/utils/cn'
import Code from './Code'
import Dialog from './Dialog'
type Properties = {
readonly type?: 'full' | 'embedded';
}
export function ErrorPopup({ type = 'full' }: Properties) {
const [isOpen, setIsOpen] = useState(false)
// eslint-disable-next-line react/hook-use-state
const open = useState(true)
const error = useRouteError()
const routing = isRouteErrorResponse(error)
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
console.error(error)
// Debounce the error modal so it doesn't show up for a split second
// when the user navigates to a new page.
useEffect(() => {
setTimeout(() => {
setIsOpen(true)
}, 150)
}, [])
return (
<Transition as={Fragment} show={isOpen}>
<Transition.Child
as={Fragment}
enter='ease-out duration-150'
enterFrom='opacity-0 scale-95'
enterTo='opacity-100 scale-100'
>
<div className={clsx(
'flex items-center justify-center overflow-clip',
type === 'full' ? 'min-h-screen' : 'mt-24'
<Dialog>
<Dialog.Panel
className={cn(
type === 'embedded' ? 'pointer-events-none bg-transparent dark:bg-transparent' : '',
)}
>
<div className={clsx(
'flex flex-col items-center justify-center space-y-2 w-full sm:w-1/2 xl:w-1/3',
'bg-white dark:bg-zinc-800 rounded-lg py-8 px-4 md:px-16',
'border border-gray-200 dark:border-zinc-700 text-center'
)}
>
<ExclamationTriangleIcon className='w-12 h-12 text-red-500'/>
<h1 className='text-2xl font-semibold text-gray-800 dark:text-gray-100'>
{routing ? error.status : 'Error'}
</h1>
{routing ? (
<p className='text-gray-500 dark:text-gray-400'>
{error.statusText}
</p>
) : (
<Code className='text-sm'>
{message}
</Code>
)}
</div>
</div>
</Transition.Child>
</Transition>
control={open}
>
{() => (
<>
<div className='flex items-center justify-between'>
<Dialog.Title className='text-3xl mb-0'>
{routing ? error.status : 'Error'}
</Dialog.Title>
<ExclamationTriangleIcon className='w-12 h-12 text-red-500'/>
</div>
<Dialog.Text className='mt-4 text-lg'>
{routing ? (
error.statusText
) : (
<Code>
{message}
</Code>
)}
</Dialog.Text>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import {
Button as AriaButton,
Menu as AriaMenu,
MenuItem,
MenuTrigger,
Popover
} from 'react-aria-components'
import { cn } from '~/utils/cn'
function Button(properties: Parameters<typeof AriaButton>[0]) {
return (
<AriaButton
{...properties}
className={cn(
'outline-none',
properties.className
)}
aria-label='Menu'
/>
)
}
function Items(properties: Parameters<typeof AriaMenu>[0]) {
return (
<Popover className={cn(
'mt-2 rounded-md',
'bg-ui-50 dark:bg-ui-800',
'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'
)}
>
<AriaMenu
{...properties}
className={cn(
'outline-none',
'divide-y divide-ui-200 dark:divide-ui-600',
properties.className
)}
>
{properties.children}
</AriaMenu>
</Popover>
)
}
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
function ItemButton(properties: ButtonProperties) {
return (
<MenuItem className='outline-none'>
<AriaButton
{...properties}
className={cn(
'px-4 py-2 w-full outline-none text-left',
'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className
)}
aria-label='Menu Dialog'
// If control is passed, set the state value
onPress={event => {
properties.onPress?.(event)
properties.control?.[1](true)
}}
/>
</MenuItem>
)
}
function Item(properties: Parameters<typeof MenuItem>[0]) {
return (
<MenuItem
{...properties}
className={cn(
'px-4 py-2 w-full outline-none',
'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className
)}
/>
)
}
function Menu({ children }: { readonly children: ReactNode }) {
return (
<MenuTrigger>
{children}
</MenuTrigger>
)
}
export default Object.assign(Menu, { Button, Item, ItemButton, Items })
-135
View File
@@ -1,135 +0,0 @@
import { Dialog, Transition } from '@headlessui/react'
import { XMarkIcon } from '@heroicons/react/24/outline'
import clsx from 'clsx'
import { Fragment, type ReactNode, type SetStateAction, useState } from 'react'
import Button from './Button'
type HookParameters = {
title: string;
description?: string;
buttonText?: string;
variant?: 'danger' | 'confirm';
children?: ReactNode;
// Optional because the button submits
onConfirm?: () => void | Promise<void>;
}
type Properties = {
readonly isOpen: boolean;
readonly setIsOpen: (value: SetStateAction<boolean>) => void;
readonly parameters: HookParameters;
}
export default function useModal(properties: HookParameters) {
const [isOpen, setIsOpen] = useState(false)
return {
Modal: (
<Modal
isOpen={isOpen}
setIsOpen={setIsOpen}
parameters={properties}
/>
),
open: () => {
setIsOpen(true)
},
close: () => {
setIsOpen(false)
}
}
}
function Modal({ parameters, isOpen, setIsOpen }: Properties) {
return (
<Transition
show={isOpen}
as={Fragment}
>
<Dialog
as='div'
className='relative z-50'
onClose={() => {
setIsOpen(false)
}}
>
<Transition.Child
enter='ease-out duration-100'
enterFrom='opacity-0'
enterTo='opacity-100'
leave='ease-in duration-75'
leaveFrom='opacity-100'
leaveTo='opacity-0'
as={Fragment}
>
<div className='fixed inset-0 bg-black/30' aria-hidden='true'/>
</Transition.Child>
<div className='fixed inset-0 flex w-screen items-center justify-center'>
<Transition.Child
enter='transition ease-out duration-100'
enterFrom='transform opacity-0 scale-95'
enterTo='transform opacity-100 scale-100'
leave='transition ease-in duration-75'
leaveFrom='transform opacity-100 scale-100'
leaveTo='transform opacity-0 scale-95'
as={Fragment}
>
<Dialog.Panel className={clsx(
'rounded-lg p-4 w-full max-w-md',
'bg-white dark:bg-black relative',
'border border-gray-200 dark:border-zinc-800'
)}
>
<XMarkIcon
className={clsx(
'absolute top-3 right-3 rounded-lg p-1.5',
'w-8 h-8 text-gray-500 dark:text-gray-400',
'hover:bg-gray-100 dark:hover:bg-zinc-800'
)}
onClick={() => {
setIsOpen(false)
}}
/>
<Dialog.Title className='text-xl font-bold'>
{parameters.title}
</Dialog.Title>
{parameters.description ? (
<Dialog.Description className='text-gray-500 dark:text-gray-400 mt-1'>
{parameters.description}
</Dialog.Description>
) : undefined}
{parameters.children ? (
<div className='mt-12 w-full'>
{parameters.children}
</div>
) : undefined}
<Button
variant='emphasized'
type='submit'
className={clsx(
'w-full',
parameters.children ? 'mt-4' : 'mt-12',
parameters.variant === 'danger'
? 'bg-red-800 dark:bg-red-500 focus:ring-red-500 dark:focus:ring-red-500'
: ''
)}
onClick={async () => {
if (parameters.onConfirm) {
await parameters.onConfirm()
}
setIsOpen(false)
}}
>
{parameters.buttonText ?? 'Confirm'}
</Button>
</Dialog.Panel>
</Transition.Child>
</div>
</Dialog>
</Transition>
)
}
+40
View File
@@ -0,0 +1,40 @@
import { Switch as AriaSwitch } from 'react-aria-components'
import { cn } from '~/utils/cn'
type SwitchProperties = Parameters<typeof AriaSwitch>[0] & {
readonly label: string;
}
export default function Switch(properties: SwitchProperties) {
return (
<AriaSwitch
{...properties}
aria-label={properties.label}
className='group flex gap-2 items-center'
>
<div
className={cn(
'flex h-[26px] w-[44px] shrink-0 cursor-default',
'rounded-full shadow-inner bg-clip-padding',
'border border-solid border-white/30 p-[3px]',
'box-border transition duration-100 ease-in-out',
'outline-none group-focus-visible:ring-2 ring-black',
'bg-main-700 dark:bg-main-800',
'group-pressed:bg-main-800 dark:group-pressed:bg-main-900',
'group-selected:bg-main-900 group-selected:group-pressed:bg-main-900',
properties.isDisabled && 'opacity-50 cursor-not-allowed',
properties.className
)}
>
<span className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white shadow transition duration-100',
'ease-in-out translate-x-0 group-selected:translate-x-[100%]'
)}
/>
</div>
</AriaSwitch>
)
}
+38
View File
@@ -0,0 +1,38 @@
import { type Dispatch, type SetStateAction } from 'react'
import {
Input,
TextField as AriaTextField
} from 'react-aria-components'
import { cn } from '~/utils/cn'
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
readonly label: string;
readonly placeholder: string;
readonly state: [string, Dispatch<SetStateAction<string>>];
}
export default function TextField(properties: TextFieldProperties) {
return (
<AriaTextField
{...properties}
aria-label={properties.label}
className='w-full'
>
<Input
placeholder={properties.placeholder}
value={properties.state[0]}
name={properties.name}
className={cn(
'block px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300',
properties.className
)}
onChange={event => {
properties.state[1](event.target.value)
}}
/>
</AriaTextField>
)
}
+71 -39
View File
@@ -1,47 +1,79 @@
import { useToaster } from 'react-hot-toast/headless'
import { XMarkIcon } from '@heroicons/react/24/outline'
import { type AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
import { ToastQueue, type ToastState, useToastQueue } from '@react-stately/toast'
import { type ReactNode, useRef } from 'react'
import { Button } from 'react-aria-components'
import { createPortal } from 'react-dom'
import { ClientOnly } from 'remix-utils/client-only'
export default function Toaster() {
const { toasts, handlers } = useToaster()
const { startPause, endPause, calculateOffset, updateHeight } = handlers
import { cn } from '~/utils/cn'
type ToastProperties = AriaToastProps<ReactNode> & {
readonly state: ToastState<ReactNode>;
}
function Toast({ state, ...properties }: ToastProperties) {
const reference = useRef(null)
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
return (
<div
className='fixed bottom-0 right-0 p-4 w-80 h-1/2 overflow-hidden'
onMouseEnter={startPause}
onMouseLeave={endPause}
{...toastProps}
ref={reference}
className={cn(
'bg-main-700 dark:bg-main-800 rounded-lg',
'text-main-100 dark:text-main-200 z-50',
'border border-main-600 dark:border-main-700',
'flex items-center justify-between p-3 pl-4 w-80'
)}
>
{toasts.slice(0, 6).map(toast => {
const offset = calculateOffset(toast, {
reverseOrder: false,
gutter: -8
})
// eslint-disable-next-line @typescript-eslint/ban-types
const reference = (element: HTMLDivElement | null) => {
if (element && typeof toast.height !== 'number') {
const { height } = element.getBoundingClientRect()
updateHeight(toast.id, -height)
}
}
return (
<div
key={toast.id}
ref={reference}
className='fixed bottom-4 right-4 p-4 bg-gray-800 rounded-lg text-white transition-all duration-300'
{...toast.ariaProps}
style={{
transform: `translateY(${offset}px) translateX(${toast.visible ? 0 : 200}%)`
}}
>
{typeof toast.message === 'function' ? (
toast.message(toast)
) : (
toast.message
)}
</div>
)
})}
<div {...titleProps}>{properties.toast.content}</div>
<Button
{...closeButtonProps}
className={cn(
'outline-none rounded-full p-1',
'hover:bg-main-600 dark:hover:bg-main-700'
)}
>
<XMarkIcon className='w-4 h-4'/>
</Button>
</div>
)
}
const toasts = new ToastQueue<ReactNode>({
maxVisibleToasts: 5
})
export function toast(text: string) {
return toasts.add(text, { timeout: 5000 })
}
export function Toaster() {
const reference = useRef(null)
const state = useToastQueue(toasts)
const { regionProps } = useToastRegion({}, state, reference)
return (
<ClientOnly>
{() => createPortal(
state.visibleToasts.length >= 0 ? (
<div
className={cn(
'fixed bottom-4 right-4',
'flex flex-col gap-4'
)}
{...regionProps}
ref={reference}
>
{state.visibleToasts.map(toast => (
<Toast key={toast.key} toast={toast} state={state}/>
))}
</div>
) : undefined,
document.body
)}
</ClientOnly>
)
}
+1 -9
View File
@@ -8,7 +8,7 @@ import {
} from '@remix-run/react'
import { ErrorPopup } from '~/components/Error'
import Toaster from '~/components/Toaster'
import { Toaster } from '~/components/Toaster'
import stylesheet from '~/tailwind.css?url'
import { getContext, registerConfigWatcher } from '~/utils/config'
@@ -33,14 +33,6 @@ export async function loader() {
throw new Error('The COOKIE_SECRET environment variable is required')
}
if (!process.env.API_KEY) {
throw new Error('The API_KEY environment variable is required')
}
if (!process.env.HEADSCALE_CONTAINER) {
throw new Error('The HEADSCALE_CONTAINER environment variable is required')
}
// eslint-disable-next-line unicorn/no-null
return null
}
+122
View File
@@ -0,0 +1,122 @@
import { json } from '@codemirror/lang-json'
import { yaml } from '@codemirror/lang-yaml'
import { useFetcher } from '@remix-run/react'
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
import CodeMirror from '@uiw/react-codemirror'
import clsx from 'clsx'
import { useEffect, useMemo, useState } from 'react'
import CodeMirrorMerge from 'react-codemirror-merge'
import Button from '~/components/Button'
import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import Fallback from './fallback'
type EditorProperties = {
readonly acl: string;
readonly setAcl: (acl: string) => void;
readonly mode: 'edit' | 'diff';
readonly data: {
hasAclWrite: boolean;
currentAcl: string;
aclType: string;
};
}
export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
const [light, setLight] = useState(false)
const [loading, setLoading] = useState(true)
const fetcher = useFetcher()
const aclType = useMemo(() => data.aclType === 'json' ? json() : yaml(), [data.aclType])
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)')
setLight(theme.matches)
theme.addEventListener('change', theme => {
setLight(theme.matches)
})
// Prevents the FOUC
setLoading(false)
}, [])
return (
<>
<div className={clsx(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden'
)}
>
<div className='overflow-y-scroll h-editor text-sm'>
{loading ? (
<Fallback acl={acl} where='client'/>
) : (
mode === 'edit' ? (
<CodeMirror
value={acl}
theme={light ? githubLight : githubDark}
extensions={[aclType]}
readOnly={!data.hasAclWrite}
onChange={value => {
setAcl(value)
}}
/>
) : (
<CodeMirrorMerge
theme={light ? githubLight : githubDark}
orientation='a-b'
>
<CodeMirrorMerge.Original
readOnly
value={data.currentAcl}
extensions={[aclType]}
/>
<CodeMirrorMerge.Modified
readOnly
value={acl}
extensions={[aclType]}
/>
</CodeMirrorMerge>
)
)}
</div>
</div>
<Button
variant='emphasized'
className='text-sm w-fit mr-2'
onClick={() => {
fetcher.submit({
acl
}, {
method: 'PATCH',
encType: 'application/json'
})
toast('Updated tailnet ACL policy')
}}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
Save
</Button>
<Button
variant='emphasized'
className={clsx(
'text-sm w-fit bg-gray-100 dark:bg-transparent',
'border border-gray-200 dark:border-gray-700'
)}
onClick={() => {
setAcl(data.currentAcl)
}}
>
Discard Changes
</Button>
</>
)
}
+51
View File
@@ -0,0 +1,51 @@
import clsx from 'clsx'
import Button from '~/components/Button'
type FallbackProperties = {
readonly acl: string;
readonly where: 'client' | 'server';
}
export default function Fallback({ acl, where }: FallbackProperties) {
return (
<>
<div className={clsx(
where === 'server' ? 'mb-2 overflow-hidden rounded-tr-lg rounded-b-lg' : '',
where === 'server' ? 'border border-gray-200 dark:border-gray-700' : ''
)}
>
<textarea
readOnly
className={clsx(
'w-full h-editor font-mono resize-none',
'text-sm text-gray-600 dark:text-gray-300',
'pl-10 pt-1 leading-snug'
)}
value={acl}
/>
</div>
{where === 'server' ? (
<>
<Button
disabled
variant='emphasized'
className='text-sm w-fit mr-2'
>
Save
</Button>
<Button
disabled
variant='emphasized'
className={clsx(
'text-sm w-fit bg-gray-100 dark:bg-transparent',
'border border-gray-200 dark:border-gray-700'
)}
>
Discard Changes
</Button>
</>
) : undefined}
</>
)
}
+158 -6
View File
@@ -1,13 +1,165 @@
import { CubeTransparentIcon } from '@heroicons/react/24/outline'
import { BeakerIcon, CubeTransparentIcon, EyeIcon, PencilSquareIcon } from '@heroicons/react/24/outline'
import { type ActionFunctionArgs, json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { useState } from 'react'
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
import { ClientOnly } from 'remix-utils/client-only'
import Notice from '~/components/Notice'
import { cn } from '~/utils/cn'
import { getAcl, getContext, patchAcl } from '~/utils/config'
import { sighupHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
import Editor from './editor'
import Fallback from './fallback'
export async function loader() {
const context = await getContext()
if (!context.hasAcl) {
throw new Error('No ACL configuration is available')
}
const { data, type } = await getAcl()
return {
hasAclWrite: context.hasAclWrite,
currentAcl: data,
aclType: type
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
status: 401
})
}
const context = await getContext()
if (!context.hasAclWrite) {
return json({ success: false }, {
status: 403
})
}
const data = await request.json() as { acl: string }
await patchAcl(data.acl)
if (context.hasDockerSock) {
await sighupHeadscale()
}
return json({ success: true })
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const [acl, setAcl] = useState(data.currentAcl)
return (
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center'>
<CubeTransparentIcon className='w-32 h-32 text-gray-500'/>
<p className='text-lg mt-8'>
Access Control Lists are currently unavailable.
They will be available in a future release.
<div>
{data.hasAclWrite ? undefined : (
<div className='mb-4'>
<Notice>
The ACL policy file is readonly to Headplane.
You will not be able to make changes here.
</Notice>
</div>
)}
<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 Tailscale documentation.
{' '}
<a
target='_blank'
rel='noreferrer'
href='https://tailscale.com/kb/1018/acls'
className='text-blue-500 dark:text-blue-400 hover:underline'
>
More information
</a>
</p>
<Tabs>
<TabList className={cn(
'flex border-t border-gray-200 dark:border-gray-700',
'w-fit rounded-t-lg overflow-hidden',
'text-gray-400 dark:text-gray-500'
)}
>
<Tab
id='edit'
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tl-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
)}
>
<PencilSquareIcon className='w-5 h-5'/>
<p>Edit file</p>
</Tab>
<Tab
id='diff'
className={({ isSelected }) => cn(
'px-4 py-2',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
)}
>
<EyeIcon className='w-5 h-5'/>
<p>Preview changes</p>
</Tab>
<Tab
id='preview'
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tr-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
)}
>
<BeakerIcon className='w-5 h-5'/>
<p>Preview rules</p>
</Tab>
</TabList>
<TabPanel id='edit'>
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode='edit'/>
)}
</ClientOnly>
</TabPanel>
<TabPanel id='diff'>
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode='diff'/>
)}
</ClientOnly>
</TabPanel>
<TabPanel id='preview'>
<div
className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
'p-16 flex flex-col items-center justify-center'
)}
>
<CubeTransparentIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
<p className='w-1/2 text-center mt-4'>
The Preview rules is very much still a work in progress.
It is a bit complicated to implement right now but hopefully it will be available soon.
</p>
</div>
</TabPanel>
</Tabs>
</div>
)
}
+49 -28
View File
@@ -1,8 +1,8 @@
import { useFetcher } from '@remix-run/react'
import Button from '~/components/Button'
import useModal from '~/components/Modal'
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
import { cn } from '~/utils/cn'
type Properties = {
readonly isEnabled: boolean;
@@ -12,38 +12,59 @@ type Properties = {
export default function Modal({ isEnabled, disabled }: Properties) {
const fetcher = useFetcher()
const { Modal, open } = useModal({
title: `${isEnabled ? 'Disable' : 'Enable'} Magic DNS`,
variant: isEnabled ? 'danger' : 'confirm',
buttonText: `${isEnabled ? 'Disable' : 'Enable'} Magic DNS`,
description: 'Devices will no longer be accessible via your tailnet domain. The search domain will also be disabled.',
onConfirm: () => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.magic_dns': !isEnabled
}, {
method: 'PATCH',
encType: 'application/json'
})
}
})
return (
<>
<Button
variant='emphasized'
className='w-fit text-sm'
disabled={disabled}
onClick={() => {
open()
}}
<Dialog>
<Dialog.Button
isDisabled={disabled}
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Button>
{Modal}
</>
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Title>
<Dialog.Text>
Devices will no longer be accessible via your tailnet domain.
The search domain will also be disabled.
</Dialog.Text>
<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={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.magic_dns': !isEnabled
}, {
method: 'PATCH',
encType: 'application/json'
})
close()
}}
>
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
+59 -40
View File
@@ -1,14 +1,14 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable unicorn/no-keyword-prefix */
import { Dialog } from '@headlessui/react'
import { useFetcher } from '@remix-run/react'
import { useState } from 'react'
import Button from '~/components/Button'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import Input from '~/components/Input'
import useModal from '~/components/Modal'
import Spinner from '~/components/Spinner'
import TextField from '~/components/TextField'
import { cn } from '~/utils/cn'
type Properties = {
readonly name: string;
@@ -19,29 +19,6 @@ type Properties = {
export default function Modal({ name, disabled }: Properties) {
const [newName, setNewName] = useState(name)
const fetcher = useFetcher()
const { Modal, open } = useModal({
title: 'Rename Tailnet',
description: 'Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet.',
buttonText: 'Rename',
children: (
<Input
type='text'
className='font-mono mt-4'
value={newName}
onChange={event => {
setNewName(event.target.value)
}}
/>
),
onConfirm: () => {
fetcher.submit({
'dns_config.base_domain': newName
}, {
method: 'PATCH',
encType: 'application/json'
})
}
})
return (
<div className='flex flex-col w-2/3'>
@@ -65,20 +42,62 @@ export default function Modal({ name, disabled }: Properties) {
event.target.select()
}}
/>
<Button
variant='emphasized'
className='text-sm w-fit'
disabled={disabled}
onClick={() => {
open()
}}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
Rename Tailnet...
</Button>
{Modal}
<Dialog>
<Dialog.Button
isDisabled={disabled}
className={cn(
'w-fit text-sm rounded-lg px-4 py-2',
'bg-main-700 dark:bg-main-800 text-white',
disabled && 'opacity-50 cursor-not-allowed'
)}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className='w-3 h-3'/>
)}
Rename Tailnet
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Rename Tailnet
</Dialog.Title>
<Dialog.Text>
Keep in mind that changing this can lead to all sorts of unexpected behavior and may break existing devices in your tailnet.
</Dialog.Text>
<TextField
label='Tailnet name'
placeholder='ts.net'
state={[newName, setNewName]}
className='my-2'
/>
<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={() => {
fetcher.submit({
'dns_config.base_domain': newName
}, {
method: 'PATCH',
encType: 'application/json'
})
close()
}}
>
Rename
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
</div>
)
}
+16 -18
View File
@@ -1,7 +1,5 @@
import { Switch } from '@headlessui/react'
import { type ActionFunctionArgs } from '@remix-run/node'
import { json, useFetcher, useLoaderData } from '@remix-run/react'
import clsx from 'clsx'
import { useState } from 'react'
import Button from '~/components/Button'
@@ -9,9 +7,11 @@ import Code from '~/components/Code'
import Input from '~/components/Input'
import Notice from '~/components/Notice'
import Spinner from '~/components/Spinner'
import Switch from '~/components/Switch'
import TableList from '~/components/TableList'
import { getConfig, getContext, patchConfig } from '~/utils/config'
import { restartHeadscale } from '~/utils/docker'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import Domains from './domains'
@@ -45,9 +45,18 @@ export async function loader() {
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
status: 401
})
}
const context = await getContext()
if (!context.hasConfigWrite) {
return json({ success: false })
return json({ success: false }, {
status: 403
})
}
const data = await request.json() as Record<string, unknown>
@@ -87,12 +96,9 @@ export default function Page() {
Override local DNS
</span>
<Switch
checked={localOverride}
disabled={!data.hasConfigWrite}
className={clsx(
localOverride ? 'bg-gray-800 dark:bg-gray-600' : 'bg-gray-200 dark:bg-gray-400',
'relative inline-flex h-4 w-9 items-center rounded-full'
)}
label='Override local DNS'
defaultSelected={localOverride}
isDisabled={!data.hasConfigWrite}
onChange={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -104,15 +110,7 @@ export default function Page() {
setLocalOverride(!localOverride)
}}
>
<span className='sr-only'>Override local DNS</span>
<span
className={clsx(
localOverride ? 'translate-x-6' : 'translate-x-1',
'inline-block h-2 w-2 transform rounded-full bg-white transition'
)}
/>
</Switch>
/>
</div>
</div>
<TableList>
-171
View File
@@ -1,171 +0,0 @@
/* eslint-disable unicorn/filename-case */
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
import { Link, useFetcher, useLoaderData } from '@remix-run/react'
import clsx from 'clsx'
import { useState } from 'react'
import { toast } from 'react-hot-toast/headless'
import Dropdown from '~/components/Dropdown'
import useModal from '~/components/Modal'
import StatusCircle from '~/components/StatusCircle'
import { type Machine } from '~/types'
import { del, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
return data.nodes
}
export async function action({ request }: ActionFunctionArgs) {
const data = await request.json() as { id?: string }
if (!data.id) {
return json({ message: 'No ID provided' }, {
status: 400
})
}
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401
})
}
await del(`v1/node/${data.id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' })
}
export default function Page() {
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>()
const [activeId, setActiveId] = useState<string | undefined>(undefined)
const fetcher = useFetcher()
const { Modal, open } = useModal({
title: 'Remove Machine',
description: [
'This action is irreversible and will disconnect the machine from the Headscale server.',
'All data associated with this machine including ACLs and tags will be lost.'
].join('\n'),
variant: 'danger',
buttonText: 'Remove',
onConfirm: () => {
fetcher.submit(
{
id: activeId!
},
{
method: 'DELETE',
encType: 'application/json'
}
)
}
})
return (
<>
{Modal}
<table className='table-auto w-full rounded-lg'>
<thead className='text-gray-500 dark:text-gray-400'>
<tr className='text-left uppercase text-sm font-bold'>
<th className='pl-4'>Name</th>
<th>IP Addresses</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody className={clsx(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border-t border-zinc-200 dark:border-zinc-700'
)}
>
{data.map(machine => {
const tags = [...machine.forcedTags, ...machine.validTags]
return (
<tr key={machine.id} className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'>
<td className='py-2 pl-4'>
<Link to={`/machines/${machine.id}`}>
<h1>{machine.givenName}</h1>
<span className='text-sm font-mono text-gray-500 dark:text-gray-400'>
{machine.name}
</span>
<div className='flex gap-1 mt-1'>
{tags.map(tag => (
<span key={tag} className='text-xs bg-gray-200 text-gray-600 rounded-sm px-1 py-0.5'>
{tag}
</span>
))}
</div>
</Link>
</td>
<td className='pt-2 pb-4 font-mono text-gray-600 dark:text-gray-300'>
{machine.ipAddresses.map((ip, index) => (
<button
key={ip}
type='button'
className='flex items-center gap-x-1 w-full'
onClick={async () => {
await navigator.clipboard.writeText(ip)
toast('Copied IP address to clipboard')
}}
>
<span className={clsx(index === 0 ? 'text-gray-600 dark:text-gray-300' : 'text-gray-400 dark:text-gray-500')}>
{ip}
</span>
<ClipboardIcon className='text-gray-400 dark:text-gray-500 w-4 h-4'/>
</button>
))}
</td>
<td className='py-2'>
<span
className='flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400'
>
<StatusCircle isOnline={machine.online} className='w-4 h-4'/>
<p>
{machine.online
? 'Connected'
: new Date(
machine.lastSeen
).toLocaleString()}
</p>
</span>
</td>
<td className='py-2 pr-4'>
<div className={clsx(
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
)}
>
<Dropdown
className='left-1/4 w-min'
button={(
<EllipsisHorizontalIcon className='w-5 h-5'/>
)}
>
<Dropdown.Item className='text-red-700'>
<button
type='button' onClick={() => {
setActiveId(machine.id)
open()
}}
>
Remove
</button>
</Dropdown.Item>
</Dropdown>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</>
)
}
@@ -0,0 +1,57 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog'
import { type Machine } from '~/types'
import { cn } from '~/utils/cn'
type DeleteProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
export default function Delete({ machine, fetcher, state }: DeleteProperties) {
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Remove {machine.givenName}
</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from
your network. To re-add it, you will need to
reauthenticate to your tailnet from the device.
</Dialog.Text>
<fetcher.Form method='POST'>
<input type='hidden' name='_method' value='delete'/>
<input type='hidden' name='id' value={machine.id}/>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white'
)}
onPress={close}
>
Remove
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -0,0 +1,57 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog'
import { type Machine } from '~/types'
import { cn } from '~/utils/cn'
type DeleteProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
export default function Expire({ machine, fetcher, state }: DeleteProperties) {
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Expire {machine.givenName}
</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet.
In order to reconnect, you will need to re-authenticate
from the device.
</Dialog.Text>
<fetcher.Form method='POST'>
<input type='hidden' name='_method' value='expire'/>
<input type='hidden' name='id' value={machine.id}/>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
className={cn(
'bg-red-500 hover:border-red-700',
'dark:bg-red-600 dark:hover:border-red-700',
'pressed:bg-red-600 hover:bg-red-600',
'text-white dark:text-white'
)}
onPress={close}
>
Expire
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -0,0 +1,89 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import { type Machine } from '~/types'
type RenameProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
}
export default function Rename({ machine, fetcher, state, magic }: RenameProperties) {
const [name, setName] = useState(machine.givenName)
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit machine name for {machine.givenName}
</Dialog.Title>
<Dialog.Text>
This name is shown in the admin panel, in Tailscale clients,
and used when generating MagicDNS names.
</Dialog.Text>
<fetcher.Form method='POST'>
<input type='hidden' name='_method' value='rename'/>
<input type='hidden' name='id' value={machine.id}/>
<TextField
label='Machine name'
placeholder='Machine name'
name='name'
state={[name, setName]}
className='my-2'
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
<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'>
{name.toLowerCase().replaceAll(/\s+/g, '-')}
</Code>
{'. '}
The hostname
{' '}
<Code className='text-sm'>
{machine.givenName}
</Code>
{' '}
will no longer point to this machine.
</p>
) : (
<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}
</Code>
.
</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}
>
Rename
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -0,0 +1,92 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import Dialog from '~/components/Dialog'
import Switch from '~/components/Switch'
import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn'
type RoutesProperties = {
readonly machine: Machine;
readonly routes: Route[];
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
}
// TODO: Support deleting routes
export default function Routes({ machine, routes, fetcher, state }: RoutesProperties) {
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit route settings of {machine.givenName}
</Dialog.Title>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on
by advertising IP ranges as subnet routes.
</Dialog.Text>
<div className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700'
)}
>
{routes.length === 0 ? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300'
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
) : undefined}
{routes.map(route => (
<div
key={route.node.id}
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between'
)}
>
<p>
{route.prefix}
</p>
<Switch
defaultSelected={route.enabled}
label='Enabled'
onChange={checked => {
const form = new FormData()
form.set('id', machine.id)
form.set('_method', 'routes')
form.set('route', route.id)
form.set('enabled', String(checked))
fetcher.submit(form, {
method: 'POST'
})
}}
/>
</div>
))}
</div>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Action
variant='cancel'
isDisabled={fetcher.state === 'submitting'}
onPress={close}
>
Close
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -0,0 +1,204 @@
/* eslint-disable react/hook-use-state */
import { ChevronDownIcon, ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'
import { type FetcherWithComponents, Link } from '@remix-run/react'
import { useState } from 'react'
import Menu from '~/components/Menu'
import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { type Machine, type Route } from '~/types'
import { cn } from '~/utils/cn'
import Delete from './dialogs/delete'
import Expire from './dialogs/expire'
import Rename from './dialogs/rename'
import Routes from './dialogs/routes'
type MachineProperties = {
readonly machine: Machine;
readonly routes: Route[];
readonly fetcher: FetcherWithComponents<unknown>;
readonly magic?: string;
}
export default function MachineRow({ machine, routes, fetcher, magic }: MachineProperties) {
const renameState = useState(false)
const expireState = useState(false)
const removeState = useState(false)
const routesState = useState(false)
const expired = new Date(machine.expiry).getTime() < Date.now()
const tags = [
...machine.forcedTags,
...machine.validTags
]
if (expired) {
tags.unshift('Expired')
}
return (
<tr
key={machine.id}
className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'
>
<td className='pl-0.5 py-2'>
<Link
to={`/machines/${machine.id}`}
className='group/link h-full'
>
<p className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400'
)}
>
{machine.givenName}
</p>
<p className='text-sm text-gray-500 dark:text-gray-300 font-mono'>
{machine.name}
</p>
<div className='flex gap-1 mt-1'>
{tags.map(tag => (
<span
key={tag}
className={cn(
'text-xs rounded-sm px-1 py-0.5',
'bg-gray-100 dark:bg-zinc-700',
'text-gray-600 dark:text-gray-300'
)}
>
{tag}
</span>
))}
</div>
</Link>
</td>
<td className='py-2'>
<div className='flex items-center gap-x-1'>
{machine.ipAddresses[0]}
<Menu>
<Menu.Button>
<ChevronDownIcon className='w-4 h-4'/>
</Menu.Button>
<Menu.Items>
{machine.ipAddresses.map(ip => (
<Menu.ItemButton
key={ip}
type='button'
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full'
)}
onPress={async () => {
await navigator.clipboard.writeText(ip)
toast('Copied IP address to clipboard')
}}
>
{ip}
<ClipboardIcon className='w-3 h-3'/>
</Menu.ItemButton>
))}
{magic ? (
<Menu.ItemButton
type='button'
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full break-keep'
)}
onPress={async () => {
const ip = `${machine.givenName}.${machine.user.name}.${magic}`
await navigator.clipboard.writeText(ip)
toast('Copied hostname to clipboard')
}}
>
{machine.givenName}.{machine.user.name}.{magic}
<ClipboardIcon className='w-3 h-3'/>
</Menu.ItemButton>
) : undefined}
</Menu.Items>
</Menu>
</div>
</td>
<td className='py-2'>
<span className={cn(
'flex items-center gap-x-1 text-sm',
'text-gray-500 dark:text-gray-400'
)}
>
<StatusCircle
isOnline={machine.online && !expired}
className='w-4 h-4'
/>
<p>
{machine.online && !expired
? 'Connected'
: new Date(
machine.lastSeen
).toLocaleString()}
</p>
</span>
</td>
<td className='py-2 pr-0.5'>
<Rename
machine={machine}
fetcher={fetcher}
state={renameState}
magic={magic}
/>
<Delete
machine={machine}
fetcher={fetcher}
state={removeState}
/>
{expired ? undefined : (
<Expire
machine={machine}
fetcher={fetcher}
state={expireState}
/>
)}
<Routes
machine={machine}
routes={routes}
fetcher={fetcher}
state={routesState}
/>
<Menu>
<Menu.Button
className={cn(
'flex items-center justify-center',
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
)}
>
<EllipsisHorizontalIcon className='w-5'/>
</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={renameState}>
Edit machine name
</Menu.ItemButton>
<Menu.ItemButton control={routesState}>
Edit route settings
</Menu.ItemButton>
<Menu.Item className='opacity-50 hover:bg-transparent'>
Edit ACL tags
</Menu.Item>
{expired ? undefined : (
<Menu.ItemButton control={expireState}>
Expire
</Menu.ItemButton>
)}
<Menu.ItemButton
className='text-red-500 dark:text-red-400'
control={removeState}
>
Remove
</Menu.ItemButton>
</Menu.Items>
</Menu>
</td>
</tr>
)
}
+165
View File
@@ -0,0 +1,165 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { InformationCircleIcon } from '@heroicons/react/24/outline'
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
import { useFetcher, 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 { cn } from '~/utils/cn'
import { getConfig, getContext } from '~/utils/config'
import { del, post, pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import MachineRow from './machine'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const machines = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
const routes = await pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!)
const context = await getContext()
let magic: string | undefined
if (context.hasConfig) {
const config = await getConfig()
if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain
}
}
return {
nodes: machines.nodes,
routes: routes.routes,
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': {
console.log('expire')
const data = await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
console.log(data)
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
})
}
}
}
export default function Page() {
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher()
return (
<>
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
<table className='table-auto w-full rounded-lg'>
<thead className='text-gray-500 dark:text-gray-400'>
<tr className='text-left uppercase text-xs font-bold px-0.5'>
<th className='pb-2'>Name</th>
<th className='pb-2'>
<div className='flex items-center gap-x-1'>
Addresses
{data.magic ? (
<TooltipTrigger delay={0}>
<Button>
<InformationCircleIcon className='w-4 h-4 text-gray-400'/>
</Button>
<Tooltip className={cn(
'text-sm max-w-xs p-2 rounded-lg mb-2',
'bg-white dark:bg-zinc-800',
'border border-gray-200 dark:border-zinc-700'
)}
>
Since MagicDNS is enabled, you can access devices
based on their name and also at
{' '}
<Code>
[name].[user].{data.magic}
</Code>
</Tooltip>
</TooltipTrigger>
) : undefined}
</div>
</th>
<th className='pb-2'>Last Seen</th>
</tr>
</thead>
<tbody className={cn(
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border-t border-zinc-200 dark:border-zinc-700'
)}
>
{data.nodes.map(machine => (
<MachineRow
key={machine.id}
// Typescript isn't smart enough yet
machine={machine as unknown as Machine}
routes={data.routes.filter(route => route.node.id === machine.id) as unknown as Route[]}
fetcher={fetcher}
magic={data.magic}
/>
))}
</tbody>
</table>
</>
)
}
+26 -32
View File
@@ -1,9 +1,9 @@
import { Cog8ToothIcon, CpuChipIcon, GlobeAltIcon, LockClosedIcon, ServerStackIcon, UserCircleIcon, UsersIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, Outlet, useLoaderData, useRouteError } from '@remix-run/react'
import { Form, Outlet, useLoaderData } from '@remix-run/react'
import Dropdown from '~/components/Dropdown'
import { ErrorPopup } from '~/components/Error'
import Menu from '~/components/Menu'
import TabLink from '~/components/TabLink'
import { getContext } from '~/utils/config'
import { HeadscaleError, pull } from '~/utils/headscale'
@@ -43,9 +43,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
export default function Layout() {
const data = useLoaderData<typeof loader>()
return (
<>
<header className='mb-16 bg-gray-800 text-white dark:bg-gray-700'>
<header className='mb-6 bg-main-700 dark:bg-main-800 text-white'>
<nav className='container mx-auto'>
<div className='flex items-center justify-between mb-8 pt-4'>
<div className='flex items-center gap-x-2'>
@@ -62,21 +63,24 @@ export default function Layout() {
<a href='https://github.com/juanfont/headscale' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
Headscale
</a>
<Dropdown
button={<UserCircleIcon className='w-8 h-8'/>}
>
<Dropdown.Item variant='static'>
<p className='font-bold'>{data.user?.name}</p>
<p>{data.user?.email}</p>
</Dropdown.Item>
<Dropdown.Item className='text-red-700'>
<Form method='POST' action='/logout'>
<button type='submit'>
Logout
</button>
</Form>
</Dropdown.Item>
</Dropdown>
<Menu>
<Menu.Button>
<UserCircleIcon className='w-8 h-8'/>
</Menu.Button>
<Menu.Items>
<Menu.Item className='text-right'>
<p className='font-bold'>{data.user?.name}</p>
<p>{data.user?.email}</p>
</Menu.Item>
<Menu.Item className='text-red-500 dark:text-red-400'>
<Form method='POST' action='/logout'>
<button type='submit' className='w-full text-right'>
Logout
</button>
</Form>
</Menu.Item>
</Menu.Items>
</Menu>
</div>
</div>
<div className='flex items-center gap-x-4'>
@@ -101,15 +105,9 @@ export default function Layout() {
}
export function ErrorBoundary() {
const data = useLoaderData<typeof loader>()
const error = useRouteError()
if (!data) {
throw error
}
return (
<>
<header className='mb-16 bg-gray-800 text-white dark:bg-gray-700'>
<header className='mb-16 bg-main-700 dark:bg-main-800 text-white'>
<nav className='container mx-auto'>
<div className='flex items-center gap-x-2 mb-8 pt-4'>
<CpuChipIcon className='w-8 h-8'/>
@@ -118,13 +116,9 @@ export function ErrorBoundary() {
<div className='flex items-center gap-x-4'>
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>
<TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/>
{data.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/> : undefined}
{data.hasConfig ? (
<>
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
</>
) : undefined}
<TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/>
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
</div>
</nav>
</header>
+1 -1
View File
@@ -52,7 +52,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const oidcStart = String(formData.get('oidc-start'))
const oidcStart = formData.get('oidc-start')
if (oidcStart) {
const context = await getContext()
+4
View File
@@ -0,0 +1,4 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
+56 -9
View File
@@ -2,7 +2,7 @@ import { type FSWatcher, watch } from 'node:fs'
import { access, constants, readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { type Document, parseDocument } from 'yaml'
import { type Document, parse, parseDocument } from 'yaml'
type Duration = `${string}s` | `${string}h` | `${string}m` | `${string}d` | `${string}y`
@@ -131,6 +131,31 @@ export async function getConfig(force = false) {
return config.toJSON() as Config
}
export async function getAcl() {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await getConfig()
path = config.acl_policy_path
} catch {}
}
if (!path) {
return { data: '', type: 'json' }
}
const data = await readFile(path, 'utf8')
// Naive check for YAML over JSON
// This is because JSON.parse doesn't support comments
try {
parse(data)
return { data, type: 'yaml' }
} catch {
return { data, type: 'json' }
}
}
// This is so obscenely dangerous, please have a check around it
export async function patchConfig(partial: Record<string, unknown>) {
for (const [key, value] of Object.entries(partial)) {
@@ -141,6 +166,22 @@ export async function patchConfig(partial: Record<string, unknown>) {
await writeFile(path, config.toString(), 'utf8')
}
export async function patchAcl(data: string) {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await getConfig()
path = config.acl_policy_path
} catch {}
}
if (!path) {
throw new Error('No ACL file defined')
}
await writeFile(path, data, 'utf8')
}
let watcher: FSWatcher
export function registerConfigWatcher() {
@@ -190,8 +231,9 @@ export async function getContext() {
async function getOidcConfig() {
// Check for the OIDC environment variables first
let issuer = process.env.OIDC_ISSUER
let client = process.env.OIDC_CLIENT
let secret = process.env.OIDC_SECRET
let client = process.env.OIDC_CLIENT_ID
let secret = process.env.OIDC_CLIENT_SECRET
const rootKey = process.env.API_KEY
if (!issuer || !client || !secret) {
const config = await getConfig()
@@ -209,6 +251,10 @@ async function getOidcConfig() {
return
}
if (!rootKey) {
throw new Error('Cannot use OIDC without the root API_KEY variable set')
}
return { issuer, client, secret }
}
@@ -266,8 +312,6 @@ async function hasAcl() {
const config = await getConfig()
path = config.acl_policy_path
} catch {}
return false
}
if (!path) {
@@ -278,7 +322,9 @@ async function hasAcl() {
path = resolve(path)
await access(path, constants.R_OK)
return true
} catch {}
} catch (error) {
console.log('Cannot acquire read access to ACL file', error)
}
return false
}
@@ -290,8 +336,6 @@ async function hasAclW() {
const config = await getConfig()
path = config.acl_policy_path
} catch {}
return false
}
if (!path) {
@@ -302,7 +346,10 @@ async function hasAclW() {
path = resolve(path)
await access(path, constants.W_OK)
return true
} catch {}
} catch (error) {
console.log('Cannot acquire read access to ACL file', error)
}
return false
}
+36 -4
View File
@@ -1,4 +1,4 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
/* eslint-disable no-await-in-loop */
/* eslint-disable no-constant-condition */
import { setTimeout } from 'node:timers/promises'
@@ -6,7 +6,32 @@ import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici'
import { getContext } from './config'
import { pull } from './headscale'
import { HeadscaleError, pull } from './headscale'
export async function sighupHeadscale() {
const context = await getContext()
if (!context.hasDockerSock) {
return
}
if (!process.env.HEADSCALE_CONTAINER) {
throw new Error('HEADSCALE_CONTAINER is not set')
}
const client = new Client('http://localhost', {
socketPath: '/var/run/docker.sock'
})
const container = process.env.HEADSCALE_CONTAINER
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${container}/kill?signal=SIGHUP`
})
if (!response.statusCode || response.statusCode !== 204) {
throw new Error('Failed to send SIGHUP to Headscale')
}
}
export async function restartHeadscale() {
const context = await getContext()
@@ -36,9 +61,16 @@ export async function restartHeadscale() {
let attempts = 0
while (true) {
try {
await pull('v1/apikey', process.env.API_KEY!)
// Acceptable blank because API_KEY is not required
await pull('v1/apikey', process.env.API_KEY ?? '')
return
} catch {
} 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
if (error instanceof HeadscaleError && error.status === 401) {
break
}
if (attempts > 10) {
throw new Error('Headscale did not restart in time')
}
+1 -1
View File
@@ -12,7 +12,7 @@ export class HeadscaleError extends Error {
export class FatalError extends Error {
constructor() {
super('The Headscale server is not accessible or the API_KEY is invalid.')
super('The Headscale server is not accessible or the supplied API key is invalid')
this.name = 'FatalError'
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 299 KiB

After

Width:  |  Height:  |  Size: 142 KiB

+4
View File
@@ -1,3 +1,7 @@
# THIS IS A DEVELOPER CONFIGURATION FILE
# IT IS NOT AN EXAMPLE OF SOMETHING YOU DEPLOY
# I ONLY USE IT FOR DEVELOPING HEADPLANE
version: '3.9'
networks:
headplane-dev:
+8 -2
View File
@@ -18,6 +18,8 @@ By default this is set to `/etc/headscale/config.yaml`.
### Access Control Lists (ACLs)
![ACL Preview](/assets/acl-preview.png)
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:
@@ -66,9 +68,8 @@ services:
ports:
- '3000:3000'
environment:
# These are always required for Headplane to work
# This is always required for Headplane to work
COOKIE_SECRET: 'abcdefghijklmnopqrstuvwxyz'
API_KEY: 'abcdefghijklmnopqrstuvwxyz'
HEADSCALE_CONTAINER: 'headscale'
DISABLE_API_KEY_LOGIN: 'true'
@@ -80,6 +81,11 @@ services:
OIDC_CLIENT_ID: 'headscale'
OIDC_ISSUER: 'https://sso.example.com'
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
# 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'
```
> For a breakdown of each configuration variable, please refer to the [Configuration](/docs/Configuration.md) guide.
+9 -1
View File
@@ -28,13 +28,17 @@ services:
- '3000:3000'
environment:
HEADSCALE_URL: 'http://headscale:8080'
API_KEY: 'abcdefghijklmnopqrstuvwxyz'
COOKIE_SECRET: 'abcdefghijklmnopqrstuvwxyz'
# These are all optional!
HEADSCALE_CONTAINER: 'headscale'
API_KEY: 'abcdefghijklmnopqrstuvwxyz'
OIDC_CLIENT_ID: 'headscale'
OIDC_ISSUER: 'https://sso.example.com'
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
DISABLE_API_KEY_LOGIN: 'true'
# These are the default values
HOST: '0.0.0.0'
PORT: '3000'
```
@@ -44,3 +48,7 @@ services:
You may also choose to run it natively with the distributed binaries on the releases page.
You'll need to manage running this yourself, and I would recommend making a `systemd` unit.
## ACL Configuration
If you would like to get the web ACL configuration working, you'll need to pass the `ACL_FILE` environment variable.
This should point to the path of the ACL file on the Headscale server (ie. `ACL_FILE=/etc/headscale/acl_policy.json`).
+3 -3
View File
@@ -4,16 +4,15 @@ You can configure Headplane using environment variables.
#### Required Variables
- **`HEADSCALE_URL`**: The public URL of your Headscale server.
- **`API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long).
- **`COOKIE_SECRET`**: A secret used to sign cookies (use a relatively long and random string).
- **`HEADSCALE_URL`**: The public URL of your Headscale server (not required if using the configuration file).
#### Optional Variables
- **`HOST`**: The host to bind the server to (default: `0.0.0.0`).
- **`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`).
- **`ACL_FILE`**: The path to the ACL file (default: `/etc/headscale/acl_policy.json`, not needed if you have `acl_policy_path` in your config).
- **`HEADSCALE_CONTAINER`**: The name of the Headscale container (required for Docker integration).
### SSO/OpenID Connect
@@ -24,6 +23,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).
- **`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.
+12 -2
View File
@@ -11,21 +11,30 @@
"typecheck": "tsc"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@headlessui/react": "^1.7.18",
"@heroicons/react": "^2.1.3",
"@react-aria/toast": "3.0.0-beta.10",
"@react-stately/toast": "3.0.0-beta.2",
"@remix-run/node": "^2.8.1",
"@remix-run/react": "^2.8.1",
"@remix-run/serve": "^2.8.1",
"@uiw/codemirror-theme-github": "^4.21.25",
"@uiw/react-codemirror": "^4.21.25",
"clsx": "^2.1.0",
"isbot": "^4.1.0",
"oauth4webapi": "^2.10.3",
"react": "^18.2.0",
"react-aria-components": "^1.1.1",
"react-codemirror-merge": "^4.21.25",
"react-dom": "^18.2.0",
"react-hot-toast": "^2.4.1",
"remix-utils": "^7.6.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-react-aria-components": "^1.1.1",
"undici": "^6.10.2",
"usehooks-ts": "^3.0.2",
"yaml": "^2.4.1"
@@ -39,6 +48,7 @@
"eslint-config-tale": "^1.0.16",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.1.6",
"vite": "^5.1.0",
"vite-tsconfig-paths": "^4.2.1"
+1833 -137
View File
File diff suppressed because it is too large Load Diff
+24 -2
View File
@@ -1,10 +1,32 @@
/* eslint-disable @typescript-eslint/naming-convention */
import type { Config } from 'tailwindcss'
import colors from 'tailwindcss/colors'
import animate from 'tailwindcss-animate'
import aria from 'tailwindcss-react-aria-components'
export default {
content: ['./app/**/*.{js,jsx,ts,tsx}'],
theme: {
extend: {}
container: {
center: true,
padding: {
DEFAULT: '1rem',
sm: '2rem',
lg: '4rem',
xl: '5rem',
'2xl': '6rem'
}
},
extend: {
height: {
editor: 'calc(100vh - 20rem)'
},
colors: {
main: colors.slate,
ui: colors.neutral
}
}
},
plugins: []
plugins: [animate, aria]
} satisfies Config
+1 -1
View File
@@ -264,7 +264,7 @@ unix_socket_permission: "0777"
# help us test it.
# OpenID Connect
oidc:
only_start_if_oidc_is_available: true
only_start_if_oidc_is_available: false
issuer: "https://sso.example.com"
client_id: "headscale"
client_secret: "super_secret_client_secret"