Compare commits

..

9 Commits

Author SHA1 Message Date
Aarnav Tale 8cce3258a2 feat: add login via oidc or api key 2024-03-18 13:06:45 -04:00
Aarnav Tale 2ff7736243 feat: support dark mode 2024-03-18 01:46:27 -04:00
Aarnav Tale fdc3cdde39 feat: add basic machine page 2024-03-17 23:24:40 -04:00
Aarnav Tale 9594468bf4 chore: wow looks like tailscale 2024-03-17 20:50:14 -04:00
Aarnav Tale db2fc29da6 chore: switch to npm 2024-03-17 19:52:42 -04:00
Aarnav Tale be59b4f0c8 chore: add readme and license 2024-03-17 19:51:56 -04:00
Aarnav Tale 1cf9cea698 fix: remove old unused garbage 2024-03-17 19:45:56 -04:00
Aarnav Tale c6c164315b feat: add machines page and the boilerplate query 2024-03-17 19:43:20 -04:00
Aarnav Tale bb493f8e1b feat: initial commit 2024-03-17 19:34:54 -04:00
102 changed files with 5096 additions and 12936 deletions
-7
View File
@@ -1,7 +0,0 @@
node_modules
.dockerignore
Dockerfile
/.cache
/build
.env
+5 -5
View File
@@ -1,7 +1,7 @@
PUBLIC_HEADSCALE_URL=https://tailscale.example.com
API_KEY=abcdefghijklmnopqrstuvwxyz
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
DISABLE_API_KEY_LOGIN=true
HEADSCALE_CONTAINER=headscale
HOST=0.0.0.0
PORT=3000
CONFIG_FILE=/etc/headscale/config.yaml
OIDC_CLIENT_ID=headscale
OIDC_CLIENT_ID=headscale
OIDC_ISSUER=https://sso.example.com
OIDC_CLIENT_SECRET=super_secret_client_secret
-3
View File
@@ -1,3 +0,0 @@
{
"extends": "tale"
}
-42
View File
@@ -1,42 +0,0 @@
name: Publish Docker Image
on:
push:
tags:
- '*'
jobs:
publish:
name: Build and Publish
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64
+7 -2
View File
@@ -1,5 +1,10 @@
.DS_Store
node_modules
/.cache
/build
/.svelte-kit
/package
.env
.env.*
!.env.example
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
-9
View File
@@ -1,9 +0,0 @@
### 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.
-21
View File
@@ -1,21 +0,0 @@
FROM node:20-alpine AS build
WORKDIR /app
RUN npm install -g pnpm
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
RUN pnpm prune --prod
FROM node:20-alpine
WORKDIR /app
COPY --from=build /app/build /app/build
COPY --from=build /app/node_modules /app/node_modules
RUN echo '{"type":"module"}' > /app/package.json
EXPOSE 3000
ENV NODE_ENV=production
ENV HOST=0.0.0.0
CMD [ "node_modules/.bin/remix-serve", "./build/server/index.js" ]
+10 -13
View File
@@ -1,24 +1,21 @@
# Headplane
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale)
![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.
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:
This is a relatively tiny SvelteKit 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.
- 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
- [ ] Editable tags, machine names, users, etc
- [ ] ACL control through Docker integration
- [ ] OIDC based login for the web UI
- [ ] Automated API key regeneration
- [ ] Editable headscale configuration
## 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.
Instructions for deploying this will come soon. It will utilize Docker to support advanced features.
## Contributing
If you would like to contribute, please install a relatively modern version of Node.js and PNPM.
Clone this repository, run `pnpm install`, and then run `pnpm dev` to start the development server.
If you would like to contribute, please install a relatively modern version of Node.js and NPM.
Clone this repository, run `npm install`, and then run `npm run dev` to start the development server.
> Copyright (c) 2024 Aarnav Tale
-40
View File
@@ -1,40 +0,0 @@
import { ClipboardIcon } from '@heroicons/react/24/outline'
import { toast } from './Toaster'
type Properties = {
readonly name: string;
readonly value: string;
readonly isCopyable?: boolean;
}
export default function Attribute({ name, value, isCopyable }: Properties) {
const canCopy = isCopyable ?? false
return (
<dl className='flex gap-1 text-sm w-full'>
<dt className='w-1/4 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1'>
{name}
</dt>
{(canCopy ?? false) ? (
<button
type='button'
className='focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md'
onClick={async () => {
await navigator.clipboard.writeText(value)
toast(`Copied ${name}`)
}}
>
<dd className='min-w-0 truncate px-2 py-1'>
{value}
</dd>
<ClipboardIcon className='text-gray-600 dark:text-gray-200 pr-2 w-max h-4'/>
</button>
) : (
<dd className='min-w-0 truncate px-2 py-1'>
{value}
</dd>
)}
</dl>
)
}
-26
View File
@@ -1,26 +0,0 @@
import clsx from 'clsx'
import { type ButtonHTMLAttributes, type DetailedHTMLProps } from 'react'
type Properties = {
readonly variant?: 'emphasized' | 'normal' | 'destructive';
} & DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
export default function Action(properties: Properties) {
return (
<button
type='button'
{...properties}
className={clsx(
'focus:outline-none focus:ring focus:ring-1',
'focus:ring-blue-500 dark:focus:ring-blue-300',
properties.className,
properties.disabled && 'opacity-50 cursor-not-allowed',
properties.variant === 'destructive' ? 'text-red-700 dark:text-red-500' : '',
properties.variant === 'emphasized' ? 'rounded-lg px-4 py-2 bg-gray-800 dark:bg-gray-700 text-white' : '',
!properties.variant || properties.variant === 'normal' ? 'text-blue-700 dark:text-blue-400' : ''
)}
>
{properties.children}
</button>
)
}
-18
View File
@@ -1,18 +0,0 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
type Properties = HTMLProps<HTMLDivElement>
export default function Card(properties: Properties) {
return (
<div
{...properties}
className={clsx(
'p-4 md:p-6 border dark:border-zinc-700 rounded-lg',
properties.className
)}
>
{properties.children}
</div>
)
}
-12
View File
@@ -1,12 +0,0 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
type Properties = HTMLProps<HTMLSpanElement>
export default function Code(properties: Properties) {
return (
<code className={clsx('bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md', properties.className)}>
{properties.children}
</code>
)
}
-142
View File
@@ -1,142 +0,0 @@
/* 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 })
-52
View File
@@ -1,52 +0,0 @@
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
import { isRouteErrorResponse, useRouteError } from '@remix-run/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) {
// 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')
return (
<Dialog>
<Dialog.Panel
className={cn(
type === 'embedded' ? 'pointer-events-none bg-transparent dark:bg-transparent' : '',
)}
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>
)
}
-26
View File
@@ -1,26 +0,0 @@
import clsx from 'clsx'
import { type DetailedHTMLProps, type InputHTMLAttributes } from 'react'
type Properties = {
readonly variant?: 'embedded' | 'normal';
} & DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>
export default function Input(properties: Properties) {
return (
<input
{...properties}
className={clsx(
'block w-full dark:text-gray-300',
'border-gray-300 dark:border-zinc-700',
'focus:outline-none focus:ring',
'focus:ring-blue-500 dark:focus:ring-blue-300',
properties.variant === 'embedded' ? 'bg-transparent' : 'dark:bg-zinc-800',
properties.variant === 'embedded' ? 'p-0' : 'px-2.5 py-1.5',
properties.variant === 'embedded' ? 'border-none' : 'border',
properties.variant === 'embedded' ? 'focus:ring-0' : 'focus:ring-1',
properties.variant === 'embedded' ? 'rounded-none' : 'rounded-lg',
properties.className
)}
/>
)
}
-98
View File
@@ -1,98 +0,0 @@
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 })
-16
View File
@@ -1,16 +0,0 @@
import { InformationCircleIcon } from '@heroicons/react/24/outline'
import clsx from 'clsx'
import { type ReactNode } from 'react'
export default function Notice({ children }: { readonly children: ReactNode }) {
return (
<div className={clsx(
'p-4 rounded-md w-fit flex items-center gap-3',
'bg-slate-400 dark:bg-slate-700'
)}
>
<InformationCircleIcon className='h-6 w-6 text-white'/>
{children}
</div>
)
}
-23
View File
@@ -1,23 +0,0 @@
import clsx from 'clsx'
type Properties = {
// eslint-disable-next-line unicorn/no-keyword-prefix
className?: string;
}
export default function Spinner(properties: Properties) {
return (
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', properties.className)}>
<div
className={clsx(
'animate-spin rounded-full w-full h-full',
'border-2 border-current border-t-transparent',
properties.className
)}
role='status'
>
<span className='sr-only'>Loading...</span>
</div>
</div>
)
}
-24
View File
@@ -1,24 +0,0 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
type Properties = HTMLProps<SVGElement> & {
readonly isOnline: boolean;
}
// eslint-disable-next-line unicorn/no-keyword-prefix
export default function StatusCircle({ isOnline, className }: Properties) {
return (
<svg
className={clsx(
className,
isOnline
? 'text-green-700 dark:text-green-400'
: 'text-gray-300 dark:text-gray-500'
)}
viewBox='0 0 24 24'
fill='currentColor'
>
<circle cx='12' cy='12' r='8'/>
</svg>
)
}
-40
View File
@@ -1,40 +0,0 @@
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>
)
}
-23
View File
@@ -1,23 +0,0 @@
import { NavLink } from '@remix-run/react'
import clsx from 'clsx'
import type { ReactNode } from 'react'
type Properties = {
readonly name: string;
readonly to: string;
readonly icon: ReactNode;
}
export default function TabLink({ name, to, icon }: Properties) {
return (
<NavLink
to={to}
className={({ isActive, isPending }) => clsx(
'flex items-center gap-x-2 p-2 border-b-2 text-md',
isActive ? 'border-white' : 'border-transparent'
)}
>
{icon} {name}
</NavLink>
)
}
-37
View File
@@ -1,37 +0,0 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
function TableList(properties: HTMLProps<HTMLDivElement>) {
return (
<div
{...properties}
className={clsx(
'border border-gray-300 rounded-lg overflow-clip',
'dark:border-zinc-700 dark:text-gray-300',
// 'dark:bg-zinc-800',
properties.className
)}
>
{properties.children}
</div>
)
}
function Item(properties: HTMLProps<HTMLDivElement>) {
return (
<div
{...properties}
className={clsx(
'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0',
'dark:border-zinc-800',
properties.className
)}
>
{properties.children}
</div>
)
}
export default Object.assign(TableList, { Item })
-38
View File
@@ -1,38 +0,0 @@
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>
)
}
-79
View File
@@ -1,79 +0,0 @@
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'
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
{...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'
)}
>
<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>
)
}
-18
View File
@@ -1,18 +0,0 @@
/**
* By default, Remix will handle hydrating your app on the client for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.client
*/
import { RemixBrowser } from '@remix-run/react'
import { startTransition, StrictMode } from 'react'
import { hydrateRoot } from 'react-dom/client'
startTransition(() => {
hydrateRoot(
document,
<StrictMode>
<RemixBrowser/>
</StrictMode>
)
})
-137
View File
@@ -1,137 +0,0 @@
/**
* By default, Remix will handle generating the HTTP Response for you.
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
* For more information, see https://remix.run/file-conventions/entry.server
*/
import { PassThrough } from 'node:stream'
import type { EntryContext } from '@remix-run/node'
import { createReadableStreamFromReadable } from '@remix-run/node'
import { RemixServer } from '@remix-run/react'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
// eslint-disable-next-line @typescript-eslint/naming-convention
const ABORT_DELAY = 5000
export default async function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return isbot(request.headers.get('user-agent') ?? '')
? handleBotRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
: handleBrowserRequest(
request,
responseStatusCode,
responseHeaders,
remixContext
)
}
async function handleBotRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onAllReady() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set('Content-Type', 'text/html')
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode
})
)
pipe(body)
},
onShellError(error: unknown) {
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error)
}
}
}
)
setTimeout(abort, ABORT_DELAY)
})
}
async function handleBrowserRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={ABORT_DELAY}
/>,
{
onShellReady() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set('Content-Type', 'text/html')
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode
})
)
pipe(body)
},
onShellError(error: unknown) {
reject(error)
},
onError(error: unknown) {
responseStatusCode = 500
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error)
}
}
}
)
setTimeout(abort, ABORT_DELAY)
})
}
-65
View File
@@ -1,65 +0,0 @@
import type { LinksFunction, MetaFunction } from '@remix-run/node'
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration
} from '@remix-run/react'
import { ErrorPopup } from '~/components/Error'
import { Toaster } from '~/components/Toaster'
import stylesheet from '~/tailwind.css?url'
import { getContext, registerConfigWatcher } from '~/utils/config'
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
{ name: 'description', content: 'A frontend for the headscale coordination server' }
]
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet }
]
export async function loader() {
const context = await getContext()
registerConfigWatcher()
if (context.headscaleUrl.length === 0) {
throw new Error('No headscale URL was provided either by the HEADSCALE_URL environment variable or the config file')
}
if (!process.env.COOKIE_SECRET) {
throw new Error('The COOKIE_SECRET environment variable is required')
}
// eslint-disable-next-line unicorn/no-null
return null
}
export function Layout({ children }: { readonly children: React.ReactNode }) {
return (
<html lang='en'>
<head>
<meta charSet='utf-8'/>
<meta name='viewport' content='width=device-width, initial-scale=1'/>
<Meta/>
<Links/>
</head>
<body className='overscroll-none dark:bg-zinc-900 dark:text-white'>
{children}
<Toaster/>
<ScrollRestoration/>
<Scripts/>
</body>
</html>
)
}
export function ErrorBoundary() {
return <ErrorPopup/>
}
export default function App() {
return <Outlet/>
}
-122
View File
@@ -1,122 +0,0 @@
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
@@ -1,51 +0,0 @@
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}
</>
)
}
-165
View File
@@ -1,165 +0,0 @@
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>
{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>
)
}
-221
View File
@@ -1,221 +0,0 @@
/* eslint-disable unicorn/no-keyword-prefix */
import {
closestCorners,
DndContext,
DragOverlay
} from '@dnd-kit/core'
import {
restrictToParentElement,
restrictToVerticalAxis
} from '@dnd-kit/modifiers'
import {
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy
} from '@dnd-kit/sortable'
import { CSS } from '@dnd-kit/utilities'
import { Bars3Icon, LockClosedIcon } from '@heroicons/react/24/outline'
import { type FetcherWithComponents, useFetcher } from '@remix-run/react'
import clsx from 'clsx'
import { useEffect, useState } from 'react'
import Button from '~/components/Button'
import Input from '~/components/Input'
import Spinner from '~/components/Spinner'
import TableList from '~/components/TableList'
type Properties = {
readonly baseDomain?: string;
readonly searchDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
}
export default function Domains({ baseDomain, searchDomains, disabled }: Properties) {
// eslint-disable-next-line unicorn/no-null, @typescript-eslint/ban-types
const [activeId, setActiveId] = useState<number | string | null>(null)
const [localDomains, setLocalDomains] = useState(searchDomains)
const [newDomain, setNewDomain] = useState('')
const fetcher = useFetcher()
useEffect(() => {
setLocalDomains(searchDomains)
}, [searchDomains])
return (
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Search Domains</h1>
<p className='text-gray-700 dark:text-gray-300 mb-2'>
Set custom DNS search domains for your Tailnet.
When using Magic DNS, your tailnet domain is used as the first search domain.
</p>
<DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners}
onDragStart={event => {
setActiveId(event.active.id)
}}
onDragEnd={event => {
// eslint-disable-next-line unicorn/no-null
setActiveId(null)
const { active, over } = event
if (!over) {
return
}
const activeItem = localDomains[active.id as number - 1]
const overItem = localDomains[over.id as number - 1]
if (!activeItem || !overItem) {
return
}
const oldIndex = localDomains.indexOf(activeItem)
const newIndex = localDomains.indexOf(overItem)
if (oldIndex !== newIndex) {
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex))
}
}}
>
<TableList>
{baseDomain ? (
<TableList.Item key='magic-dns-sd'>
<p className='font-mono text-sm'>{baseDomain}</p>
<LockClosedIcon className='h-4 w-4'/>
</TableList.Item>
) : undefined}
<SortableContext
items={localDomains}
strategy={verticalListSortingStrategy}
>
{localDomains.map((sd, index) => (
<Domain
// eslint-disable-next-line react/no-array-index-key
key={index}
domain={sd}
id={index + 1}
localDomains={localDomains}
disabled={disabled}
fetcher={fetcher}
/>
))}
<DragOverlay adjustScale>
{activeId ? <Domain
isDrag
domain={localDomains[activeId as number - 1]}
localDomains={localDomains}
id={activeId as number - 1}
disabled={disabled}
fetcher={fetcher}
/> : undefined}
</DragOverlay>
</SortableContext>
{disabled ? undefined : (
<TableList.Item key='add-sd'>
<Input
variant='embedded'
type='text'
className='font-mono text-sm'
placeholder='Search Domain'
value={newDomain}
onChange={event => {
setNewDomain(event.target.value)
}}
/>
{fetcher.state === 'idle' ? (
<Button
className='text-sm'
disabled={newDomain.length === 0}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': [...localDomains, newDomain]
}, {
method: 'PATCH',
encType: 'application/json'
})
setNewDomain('')
}}
>
Add
</Button>
) : (
<Spinner className='w-3 h-3 mr-0'/>
)}
</TableList.Item>
)}
</TableList>
</DndContext>
</div>
)
}
type DomainProperties = {
readonly domain: string;
readonly id: number;
readonly isDrag?: boolean;
readonly localDomains: string[];
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
readonly fetcher: FetcherWithComponents<unknown>;
}
function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainProperties) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging
} = useSortable({ id })
// TODO: Figure out why TableList.Item breaks dndkit
return (
<div
ref={setNodeRef}
className={clsx(
'flex items-center justify-between px-3 py-2',
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
isDragging ? 'text-gray-400' : '',
isDrag ? 'outline outline-1 outline-gray-500 bg-gray-200 dark:bg-zinc-800' : ''
)}
style={{
transform: CSS.Transform.toString(transform),
transition
}}
>
<p className='font-mono text-sm flex items-center gap-4'>
{disabled ? undefined : (
<Bars3Icon
className='h-4 w-4 text-gray-400 focus:outline-none'
{...attributes}
{...listeners}
/>
)}
{domain}
</p>
{isDrag ? undefined : (
<Button
variant='destructive'
className='text-sm'
disabled={disabled}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': localDomains.filter((_, index) => index !== id - 1)
}, {
method: 'PATCH',
encType: 'application/json'
})
}}
>
Remove
</Button>
)}
</div>
)
}
-70
View File
@@ -1,70 +0,0 @@
import { useFetcher } from '@remix-run/react'
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
import { cn } from '~/utils/cn'
type Properties = {
readonly isEnabled: boolean;
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
}
export default function Modal({ isEnabled, disabled }: Properties) {
const fetcher = useFetcher()
return (
<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
</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>
)
}
-103
View File
@@ -1,103 +0,0 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable unicorn/no-keyword-prefix */
import { useFetcher } from '@remix-run/react'
import { useState } from 'react'
import Code from '~/components/Code'
import Dialog from '~/components/Dialog'
import Input from '~/components/Input'
import Spinner from '~/components/Spinner'
import TextField from '~/components/TextField'
import { cn } from '~/utils/cn'
type Properties = {
readonly name: string;
// eslint-disable-next-line react/boolean-prop-naming
readonly disabled?: boolean;
}
export default function Modal({ name, disabled }: Properties) {
const [newName, setNewName] = useState(name)
const fetcher = useFetcher()
return (
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Tailnet Name</h1>
<p className='text-gray-700 dark:text-gray-300'>
This is the base domain name of your Tailnet.
Devices are accessible at
{' '}
<Code>
[device].[user].{name}
</Code>
{' '}
when Magic DNS is enabled.
</p>
<Input
readOnly
className='font-mono text-sm my-4 w-1/2'
type='text'
value={name}
onFocus={event => {
event.target.select()
}}
/>
<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>
)
}
-201
View File
@@ -1,201 +0,0 @@
import { type ActionFunctionArgs } from '@remix-run/node'
import { json, useFetcher, useLoaderData } from '@remix-run/react'
import { useState } from 'react'
import Button from '~/components/Button'
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'
import MagicModal from './magic'
import RenameModal from './rename'
// We do not want to expose every config value
export async function loader() {
const context = await getContext()
if (!context.hasConfig) {
throw new Error('No configuration is available')
}
const config = await getConfig()
const dns = {
prefixes: config.prefixes,
magicDns: config.dns_config.magic_dns,
baseDomain: config.dns_config.base_domain,
overrideLocal: config.dns_config.override_local_dns,
nameservers: config.dns_config.nameservers,
splitDns: config.dns_config.restricted_nameservers,
searchDomains: config.dns_config.domains,
extraRecords: config.dns_config.extra_records
}
return {
...dns,
...context
}
}
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 }, {
status: 403
})
}
const data = await request.json() as Record<string, unknown>
await patchConfig(data)
await restartHeadscale()
return json({ success: true })
}
export default function Page() {
useLiveData({ interval: 5000 })
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher()
const [localOverride, setLocalOverride] = useState(data.overrideLocal)
const [ns, setNs] = useState('')
return (
<div className='flex flex-col gap-16 max-w-screen-lg'>
{data.hasConfigWrite ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to the configuration
</Notice>
)}
<RenameModal name={data.baseDomain} disabled={!data.hasConfigWrite}/>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Nameservers</h1>
<p className='text-gray-700 dark:text-gray-300'>
Set the nameservers used by devices on the Tailnet
to resolve DNS queries.
</p>
<div className='mt-4'>
<div className='flex items-center justify-between mb-2'>
<h2 className='text-md font-medium opacity-80'>
Global Nameservers
</h2>
<div className='flex gap-2 items-center'>
<span className='text-sm opacity-50'>
Override local DNS
</span>
<Switch
label='Override local DNS'
defaultSelected={localOverride}
isDisabled={!data.hasConfigWrite}
onChange={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.override_local_dns': !localOverride
}, {
method: 'PATCH',
encType: 'application/json'
})
setLocalOverride(!localOverride)
}}
/>
</div>
</div>
<TableList>
{data.nameservers.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className='font-mono text-sm'>{ns}</p>
<Button
variant='destructive'
className='text-sm'
disabled={!data.hasConfigWrite}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': data.nameservers.filter((_, index_) => index_ !== index)
}, {
method: 'PATCH',
encType: 'application/json'
})
}}
>
Remove
</Button>
</TableList.Item>
))}
{data.hasConfigWrite ? (
<TableList.Item>
<Input
variant='embedded'
type='text'
className='font-mono text-sm'
placeholder='Nameserver'
value={ns}
onChange={event => {
setNs(event.target.value)
}}
/>
{fetcher.state === 'idle' ? (
<Button
className='text-sm'
disabled={ns.length === 0}
onClick={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.nameservers': [...data.nameservers, ns]
}, {
method: 'PATCH',
encType: 'application/json'
})
setNs('')
}}
>
Add
</Button>
) : (
<Spinner className='w-3 h-3 mr-0'/>
)}
</TableList.Item>
) : undefined}
</TableList>
{/* TODO: Split DNS and Custom A Records */}
</div>
</div>
<Domains
baseDomain={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
disabled={!data.hasConfigWrite}
/>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Magic DNS</h1>
<p className='text-gray-700 dark:text-gray-300 mb-4'>
Automaticall register domain names for each device
on the tailnet. Devices will be accessible at
{' '}
<Code>
[device].[user].{data.baseDomain}
</Code>
{' '}
when Magic DNS is enabled.
</p>
<MagicModal isEnabled={data.magicDns} disabled={!data.hasConfigWrite}/>
</div>
</div>
)
}
-75
View File
@@ -1,75 +0,0 @@
import { 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 } from '~/types'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!params.id) {
throw new Error('No machine ID provided')
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!)
return data.node
}
export default function Page() {
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 1000 })
return (
<div>
<p className='mb-4 text-gray-500 dark:text-gray-400 text-sm'>
<Link
to='/machines'
className='font-bold text-gray-700 dark:text-gray-300 hover:underline'
>
All Machines
</Link>
{' / '}
{data.givenName}
</p>
<span className='flex items-baseline gap-x-4 text-sm mb-4'>
<h1 className='text-2xl font-bold'>
{data.givenName}
</h1>
<StatusCircle isOnline={data.online} className='w-4 h-4'/>
</span>
<Card>
<Attribute name='Creator' value={data.user.name}/>
<Attribute name='Node ID' value={data.id}/>
<Attribute name='Node Name' value={data.givenName}/>
<Attribute name='Hostname' value={data.name}/>
<Attribute
isCopyable
name='Node Key'
value={data.nodeKey}
/>
<Attribute
name='Created'
value={new Date(data.createdAt).toLocaleString()}
/>
<Attribute
name='Last Seen'
value={new Date(data.lastSeen).toLocaleString()}
/>
<Attribute
name='Expiry'
value={new Date(data.expiry).toLocaleString()}
/>
<Attribute
isCopyable
name='Domain'
value={`${data.givenName}.${data.user.name}.ts.net`}
/>
</Card>
</div>
)
}
@@ -1,57 +0,0 @@
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>
)
}
@@ -1,57 +0,0 @@
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>
)
}
@@ -1,89 +0,0 @@
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>
)
}
@@ -1,92 +0,0 @@
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>
)
}
@@ -1,204 +0,0 @@
/* 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
@@ -1,165 +0,0 @@
/* 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>
</>
)
}
-13
View File
@@ -1,13 +0,0 @@
import { CubeTransparentIcon } from '@heroicons/react/24/outline'
export default function Page() {
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'>
The settings page is currently unavailable.
It will be available in a future release.
</p>
</div>
)
}
-128
View File
@@ -1,128 +0,0 @@
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 } from '@remix-run/react'
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'
import { destroySession, getSession } from '~/utils/sessions'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return redirect('/login')
}
try {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await pull('v1/apikey', session.get('hsApiKey')!)
} catch (error) {
if (error instanceof HeadscaleError) {
console.error(error)
// Safest to just redirect to login if we can't pull
return redirect('/login', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await destroySession(session)
}
})
}
// Otherwise propagate to boundary
throw error
}
const context = await getContext()
return {
...context,
user: session.get('user')
}
}
export default function Layout() {
const data = useLoaderData<typeof loader>()
return (
<>
<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'>
<CpuChipIcon className='w-8 h-8'/>
<h1 className='text-2xl'>Headplane</h1>
</div>
<div className='flex items-center gap-x-4'>
<a href='https://tailscale.com/download' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
Download
</a>
<a href='https://github.com/tale/headplane' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
GitHub
</a>
<a href='https://github.com/juanfont/headscale' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
Headscale
</a>
<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'>
<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}
</div>
</nav>
</header>
<main className='container mx-auto overscroll-contain mb-24'>
<Outlet/>
</main>
</>
)
}
export function ErrorBoundary() {
return (
<>
<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'/>
<h1 className='text-2xl'>Headplane</h1>
</div>
<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'/>}/>
<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>
<ErrorPopup type='embedded'/>
</>
)
}
-67
View File
@@ -1,67 +0,0 @@
/* eslint-disable unicorn/filename-case */
import { ClipboardIcon, UserIcon } from '@heroicons/react/24/outline'
import { type LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { toast } from 'react-hot-toast/headless'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { type Machine, type User } from '~/types'
import { 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'))
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
const users = new Map<string, Machine[]>()
for (const machine of data.nodes) {
const { user } = machine
if (!users.has(user.id)) {
users.set(user.id, [])
}
users.get(user.id)?.push(machine)
}
return [...users.values()].map(machines => {
const { user } = machines[0]
return {
...user,
machines
}
})
}
export default function Page() {
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 3000 })
return (
<div className='grid grid-cols-2 gap-4 auto-rows-min'>
{data.map(user => (
<Card key={user.id}>
<div className='flex items-center gap-4'>
<UserIcon className='w-6 h-6'/>
<span className='text-lg font-mono'>
{user.name}
</span>
</div>
<div className='py-4'>
{user.machines.map(machine => (
<div key={machine.id} className='flex items-center w-full gap-4'>
<StatusCircle isOnline={machine.online} className='w-4 h-4 px-1 w-fit'/>
<Attribute name={`Node ${machine.id}`} value={machine.givenName}/>
</div>
))}
</div>
</Card>
))}
</div>
)
}
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from '@remix-run/node'
export function loader() {
return redirect('/machines')
}
-169
View File
@@ -1,169 +0,0 @@
import { type ActionFunctionArgs, json, type LoaderFunctionArgs, redirect } from '@remix-run/node'
import { Form, useActionData, useLoaderData } from '@remix-run/react'
import { useMemo } from 'react'
import Button from '~/components/Button'
import Card from '~/components/Card'
import Code from '~/components/Code'
import Input from '~/components/Input'
import { type Key } from '~/types'
import { getContext } from '~/utils/config'
import { pull } from '~/utils/headscale'
import { startOidc } from '~/utils/oidc'
import { commitSession, getSession } from '~/utils/sessions'
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (session.has('hsApiKey')) {
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
})
}
const context = await getContext()
const issuer = context.oidcConfig?.issuer
const id = context.oidcConfig?.client
const secret = context.oidcConfig?.secret
const normal = process.env.DISABLE_API_KEY_LOGIN
if (issuer && (!id || !secret)) {
throw new Error('An invalid OIDC configuration was provided')
}
const data = {
oidc: issuer,
apiKey: normal === undefined
}
if (!data.oidc && !data.apiKey) {
throw new Error('No authentication method is enabled')
}
if (data.oidc && !data.apiKey) {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return startOidc(data.oidc, id!, request)
}
return data
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const oidcStart = formData.get('oidc-start')
if (oidcStart) {
const context = await getContext()
const issuer = context.oidcConfig?.issuer
const id = context.oidcConfig?.client
// We know it exists here because this action only happens on OIDC
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return startOidc(issuer!, id!, request)
}
const apiKey = String(formData.get('api-key'))
const session = await getSession(request.headers.get('Cookie'))
// Test the API key
try {
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey)
const key = apiKeys.apiKeys.find(k => apiKey.startsWith(k.prefix))
if (!key) {
throw new Error('Invalid API key')
}
const expiry = new Date(key.expiration)
const expiresIn = expiry.getTime() - Date.now()
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24)
session.set('hsApiKey', apiKey)
session.set('user', {
name: key.prefix,
email: `${expiresDays} days`
})
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session, {
maxAge: expiresIn
})
}
})
} catch (error) {
console.error(error)
return json({
error: 'Invalid API key'
})
}
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const actionData = useActionData<typeof action>()
const showOr = useMemo(() => data.oidc && data.apiKey, [data])
return (
<div className='flex min-h-screen items-center justify-center'>
<Card className='w-96'>
<h1 className='text-2xl mb-8'>Login</h1>
{data.apiKey ? (
<Form method='post'>
<p className='text-sm text-gray-500 mb-4'>
Enter an API key to authenticate with Headplane. You can generate
one by running
{' '}
<Code>
headscale apikeys create
</Code>
{' '}
in your terminal.
</p>
{actionData?.error ? (
<p className='text-red-500 text-sm mb-2'>{actionData.error}</p>
) : undefined}
<Input
required
type='text'
name='api-key'
id='api-key'
className='border rounded-md p-2 w-full'
placeholder='API Key'
/>
<Button
variant='emphasized'
type='submit'
className='bg-gray-800 text-white rounded-md p-2 w-full mt-4'
>
Login
</Button>
</Form>
) : undefined}
{showOr ? (
<div className='flex items-center gap-x-2 py-2'>
<hr className='flex-1 dark:border-zinc-700'/>
<span className='text-gray-500'>or</span>
<hr className='flex-1 dark:border-zinc-700'/>
</div>
) : undefined}
{data.oidc ? (
<Form method='POST'>
<input type='hidden' name='oidc-start' value='true'/>
<Button
variant='emphasized'
type='submit'
className='bg-gray-800 text-white rounded-md p-2 w-full'
>
Login with SSO
</Button>
</Form>
) : undefined}
</Card>
</div>
)
}
-15
View File
@@ -1,15 +0,0 @@
import { type ActionFunctionArgs, redirect } from '@remix-run/node'
import { destroySession, getSession } from '~/utils/sessions'
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
const returnTo = new URL(request.url).pathname
return redirect(`/login?returnTo=${returnTo}`, {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await destroySession(session)
}
})
}
-15
View File
@@ -1,15 +0,0 @@
import { type LoaderFunctionArgs } from '@remix-run/node'
import { getContext } from '~/utils/config'
import { finishOidc } from '~/utils/oidc'
export async function loader({ request }: LoaderFunctionArgs) {
const context = await getContext()
const oidc = context.oidcConfig
if (!oidc) {
throw new Error('An invalid OIDC configuration was provided')
}
return finishOidc(oidc.issuer, oidc.client, oidc.secret, request)
}
-3
View File
@@ -1,3 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
-7
View File
@@ -1,7 +0,0 @@
export type Key = {
id: string;
prefix: string;
expiration: string;
createdAt: Date;
lastSeen: Date;
}
-4
View File
@@ -1,4 +0,0 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
-355
View File
@@ -1,355 +0,0 @@
import { type FSWatcher, watch } from 'node:fs'
import { access, constants, readFile, writeFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { type Document, parse, parseDocument } from 'yaml'
type Duration = `${string}s` | `${string}h` | `${string}m` | `${string}d` | `${string}y`
type Config = {
server_url: string;
listen_addr: string;
metrics_listen_addr: string;
grpc_listen_addr: string;
grpc_allow_insecure: boolean;
private_key_path: string;
noise: {
private_key_path: string;
};
prefixes: {
v4: string;
v6: string;
};
derp: {
server: {
enabled: boolean;
region_id: number;
region_code: string;
region_name: string;
stun_listen_addr: string;
};
urls: string[];
paths: string[];
auto_update_enabled: boolean;
update_frequency: Duration;
};
disable_check_updates: boolean;
epheremal_node_inactivity_timeout: Duration;
node_update_check_interval: Duration;
// Database is probably dangerous
database: {
type: 'sqlite3' | 'sqlite' | 'postgres';
sqlite?: {
path: string;
};
postgres?: {
host: string;
port: number;
name: string;
user: string;
pass: string;
max_open_conns: number;
max_idle_conns: number;
conn_max_idle_time_secs: number;
ssl: boolean;
};
};
acme_url: string;
acme_email: string;
tls_letsencrypt_hostname: string;
tls_letsencrypt_cache_dir: string;
tls_letsencrypt_challenge_type: string;
tls_letsencrypt_listen: string;
tls_cert_path: string;
tls_key_path: string;
log: {
format: 'text' | 'json';
level: string;
};
acl_policy_path: string;
dns_config: {
override_local_dns: boolean;
nameservers: string[];
restricted_nameservers: Record<string, string[]>; // Split DNS
domains: string[];
extra_records: Array<{
name: string;
type: 'A';
value: string;
}>;
magic_dns: boolean;
base_domain: string;
};
unix_socket: string;
unix_socket_permission: string;
oidc: {
only_start_if_oidc_is_available: boolean;
issuer: string;
client_id: string;
client_secret: string;
expiry: Duration;
use_expiry_from_token: boolean;
scope: string[];
extra_params: Record<string, string>;
allowed_domains: string[];
allowed_groups: string[];
allowed_users: string[];
strip_email_domain: boolean;
};
logtail: {
enabled: boolean;
};
randomize_client_port: boolean;
}
let config: Document
export async function getConfig(force = false) {
if (!config || force) {
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
const data = await readFile(path, 'utf8')
config = parseDocument(data)
}
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)) {
config.setIn(key.split('.'), value)
}
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
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() {
if (watcher) {
return
}
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
watcher = watch(path, async () => {
console.log('Config file changed, reloading')
await getConfig(true)
})
}
type Context = {
hasDockerSock: boolean;
hasConfig: boolean;
hasConfigWrite: boolean;
hasAcl: boolean;
hasAclWrite: boolean;
headscaleUrl: string;
oidcConfig?: {
issuer: string;
client: string;
secret: string;
};
}
export let context: Context
export async function getContext() {
if (!context) {
context = {
hasDockerSock: await checkSock(),
hasConfig: await hasConfig(),
hasConfigWrite: await hasConfigW(),
hasAcl: await hasAcl(),
hasAclWrite: await hasAclW(),
headscaleUrl: await getHeadscaleUrl(),
oidcConfig: await getOidcConfig()
}
}
return context
}
async function getOidcConfig() {
// Check for the OIDC environment variables first
let issuer = process.env.OIDC_ISSUER
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()
issuer = config.oidc?.issuer
client = config.oidc?.client_id
secret = config.oidc?.client_secret
}
// If atleast one is defined but not all 3, throw an error
if ((issuer || client || secret) && !(issuer && client && secret)) {
throw new Error('OIDC configuration is incomplete')
}
if (!issuer || !client || !secret) {
return
}
if (!rootKey) {
throw new Error('Cannot use OIDC without the root API_KEY variable set')
}
return { issuer, client, secret }
}
async function getHeadscaleUrl() {
if (process.env.HEADSCALE_URL) {
return process.env.HEADSCALE_URL
}
try {
const config = await getConfig()
if (config.server_url) {
return config.server_url
}
} catch {}
return ''
}
async function checkSock() {
try {
await access('/var/run/docker.sock', constants.R_OK)
return true
} catch {}
if (!process.env.HEADSCALE_CONTAINER) {
return false
}
return false
}
async function hasConfig() {
try {
await getConfig()
return true
} catch {}
return false
}
async function hasConfigW() {
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
try {
await access(path, constants.W_OK)
return true
} catch {}
return false
}
async function hasAcl() {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await getConfig()
path = config.acl_policy_path
} catch {}
}
if (!path) {
return false
}
try {
path = resolve(path)
await access(path, constants.R_OK)
return true
} catch (error) {
console.log('Cannot acquire read access to ACL file', error)
}
return false
}
async function hasAclW() {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await getConfig()
path = config.acl_policy_path
} catch {}
}
if (!path) {
return false
}
try {
path = resolve(path)
await access(path, constants.W_OK)
return true
} catch (error) {
console.log('Cannot acquire read access to ACL file', error)
}
return false
}
-82
View File
@@ -1,82 +0,0 @@
/* eslint-disable no-await-in-loop */
/* eslint-disable no-constant-condition */
import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici'
import { getContext } from './config'
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()
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}/restart`
})
if (!response.statusCode || response.statusCode !== 204) {
throw new Error('Failed to restart Headscale')
}
// Wait for Headscale to restart before continuing
let attempts = 0
while (true) {
try {
// Acceptable blank because API_KEY is not required
await pull('v1/apikey', process.env.API_KEY ?? '')
return
} catch (error) {
// This means the server is up but the API key is invalid
// This can happen if the user only uses API_KEY via cookies
if (error instanceof HeadscaleError && error.status === 401) {
break
}
if (attempts > 10) {
throw new Error('Headscale did not restart in time')
}
attempts++
await setTimeout(1000)
}
}
}
-69
View File
@@ -1,69 +0,0 @@
import { getContext } from './config'
export class HeadscaleError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.name = 'HeadscaleError'
this.status = status
}
}
export class FatalError extends Error {
constructor() {
super('The Headscale server is not accessible or the supplied API key is invalid')
this.name = 'FatalError'
}
}
export async function pull<T>(url: string, key: string) {
const context = await getContext()
const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, {
headers: {
Authorization: `Bearer ${key}`
}
})
if (!response.ok) {
throw new HeadscaleError(await response.text(), response.status)
}
return (response.json() as Promise<T>)
}
export async function post<T>(url: string, key: string, body?: unknown) {
const context = await getContext()
const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`
}
})
if (!response.ok) {
throw new HeadscaleError(await response.text(), response.status)
}
return (response.json() as Promise<T>)
}
export async function del<T>(url: string, key: string) {
const context = await getContext()
const prefix = context.headscaleUrl
const response = await fetch(`${prefix}/api/${url}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${key}`
}
})
if (!response.ok) {
throw new HeadscaleError(await response.text(), response.status)
}
return (response.json() as Promise<T>)
}
-146
View File
@@ -1,146 +0,0 @@
import { redirect } from '@remix-run/node'
import {
authorizationCodeGrantRequest,
calculatePKCECodeChallenge, type Client,
discoveryRequest,
generateRandomCodeVerifier,
generateRandomNonce,
generateRandomState,
getValidatedIdTokenClaims, isOAuth2Error,
parseWwwAuthenticateChallenges,
processAuthorizationCodeOpenIDResponse,
processDiscoveryResponse,
validateAuthResponse } from 'oauth4webapi'
import { post } from '~/utils/headscale'
import { commitSession, getSession } from '~/utils/sessions'
export async function startOidc(issuer: string, client: string, request: Request) {
const session = await getSession(request.headers.get('Cookie'))
if (session.has('hsApiKey')) {
return redirect('/', {
status: 302,
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
})
}
const issuerUrl = new URL(issuer)
const oidcClient = {
client_id: client,
token_endpoint_auth_method: 'client_secret_basic'
} satisfies Client
const response = await discoveryRequest(issuerUrl)
const processed = await processDiscoveryResponse(issuerUrl, response)
if (!processed.authorization_endpoint) {
throw new Error('No authorization endpoint found on the OIDC provider')
}
const state = generateRandomState()
const nonce = generateRandomNonce()
const verifier = generateRandomCodeVerifier()
const challenge = await calculatePKCECodeChallenge(verifier)
const callback = new URL('/admin/oidc/callback', request.url)
callback.protocol = request.url.includes('localhost') ? 'http:' : 'https:'
callback.hostname = request.headers.get('Host') ?? ''
const authUrl = new URL(processed.authorization_endpoint)
authUrl.searchParams.set('client_id', oidcClient.client_id)
authUrl.searchParams.set('response_type', 'code')
authUrl.searchParams.set('redirect_uri', callback.href)
authUrl.searchParams.set('scope', 'openid profile email')
authUrl.searchParams.set('code_challenge', challenge)
authUrl.searchParams.set('code_challenge_method', 'S256')
authUrl.searchParams.set('state', state)
authUrl.searchParams.set('nonce', nonce)
session.set('authState', state)
session.set('authNonce', nonce)
session.set('authVerifier', verifier)
return redirect(authUrl.href, {
status: 302,
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
})
}
export async function finishOidc(issuer: string, client: string, secret: string, request: Request) {
const session = await getSession(request.headers.get('Cookie'))
if (session.has('hsApiKey')) {
return redirect('/', {
status: 302,
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
})
}
const issuerUrl = new URL(issuer)
const oidcClient = {
client_id: client,
client_secret: secret,
token_endpoint_auth_method: 'client_secret_basic'
} satisfies Client
const response = await discoveryRequest(issuerUrl)
const processed = await processDiscoveryResponse(issuerUrl, response)
if (!processed.authorization_endpoint) {
throw new Error('No authorization endpoint found on the OIDC provider')
}
const state = session.get('authState')
const nonce = session.get('authNonce')
const verifier = session.get('authVerifier')
if (!state || !nonce || !verifier) {
throw new Error('No OIDC state found in the session')
}
const parameters = validateAuthResponse(processed, oidcClient, new URL(request.url), state)
if (isOAuth2Error(parameters)) {
throw new Error('Invalid response from the OIDC provider')
}
const callback = new URL('/admin/oidc/callback', request.url)
callback.protocol = request.url.includes('localhost') ? 'http:' : 'https:'
callback.hostname = request.headers.get('Host') ?? ''
const tokenResponse = await authorizationCodeGrantRequest(processed, oidcClient, parameters, callback.href, verifier)
const challenges = parseWwwAuthenticateChallenges(tokenResponse)
if (challenges) {
throw new Error('Recieved a challenge from the OIDC provider')
}
const result = await processAuthorizationCodeOpenIDResponse(processed, oidcClient, tokenResponse, nonce)
if (isOAuth2Error(result)) {
throw new Error('Invalid response from the OIDC provider')
}
const claims = getValidatedIdTokenClaims(result)
const expDate = new Date(claims.exp * 1000).toISOString()
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const keyResponse = await post<{ apiKey: string }>('v1/apikey', process.env.API_KEY!, {
expiration: expDate
})
session.set('hsApiKey', keyResponse.apiKey)
session.set('user', {
name: claims.name ? String(claims.name) : 'Anonymous',
email: claims.email ? String(claims.email) : undefined
})
return redirect('/machines', {
headers: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Set-Cookie': await commitSession(session)
}
})
}
-36
View File
@@ -1,36 +0,0 @@
import { createCookieSessionStorage } from '@remix-run/node' // Or cloudflare/deno
type SessionData = {
hsApiKey: string;
authState: string;
authNonce: string;
authVerifier: string;
user: {
name: string;
email?: string;
};
}
type SessionFlashData = {
error: string;
}
export const {
getSession,
commitSession,
destroySession
} = createCookieSessionStorage<SessionData, SessionFlashData>(
{
cookie: {
name: 'hp_sess',
httpOnly: true,
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
sameSite: 'lax',
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secrets: [process.env.COOKIE_SECRET!],
secure: true
}
}
)
-34
View File
@@ -1,34 +0,0 @@
import { useRevalidator } from '@remix-run/react'
import { useEffect } from 'react'
import { useInterval } from 'usehooks-ts'
type Properties = {
interval: number;
}
export function useLiveData({ interval }: Properties) {
const revalidator = useRevalidator()
// Handle normal stale-while-revalidate behavior
useInterval(() => {
if (revalidator.state === 'idle') {
revalidator.revalidate()
}
}, interval)
useEffect(() => {
const handler = () => {
if (revalidator.state === 'idle') {
revalidator.revalidate()
}
}
window.addEventListener('online', handler)
document.addEventListener('focus', handler)
return () => {
window.removeEventListener('online', handler)
document.removeEventListener('focus', handler)
}
}, [revalidator])
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

-24
View File
@@ -1,24 +0,0 @@
# 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:
name: 'headplane-dev'
driver: 'bridge'
services:
headscale:
image: 'headscale/headscale:0.23.0-alpha5'
container_name: 'headscale'
restart: 'unless-stopped'
command: 'serve'
networks:
- 'headplane-dev'
volumes:
- './.cache/headscale:/var/lib/headscale'
- './test:/etc/headscale'
ports:
- '8080:8080'
environment:
TZ: 'America/New_York'
-126
View File
@@ -1,126 +0,0 @@
# Advanced Integration
![Integration Preview](/assets/advanced-preview.png)
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.
If you want to use this integration, you do not need Docker and you can make it work with Headscale and Headplane running natively.
### Configuration Editing
When the configuration file is available to Headplane, the `DNS` and `Settings` tabs will become functional.
Similar to the Tailscale UI, you'll be able to edit the configuration without needing to manually edit the file.
Headscale will read the file from the path given in the `CONFIG_FILE` environment variable.
By default this is set to `/etc/headscale/config.yaml`.
> One important think to note is that environment variables always take priority over the configuration file.
> The `HEADSCALE_URL`, `OIDC_CLIENT_ID`, `OIDFC_ISSUER`, and `OIDC_CLIENT_SECRET` will be preferred over the configuration file if available.
### 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:
- **Environment Variable**: If you set the `ACL_FILE` environment variable, Headplane will read the file from that path.
- **Configuration Integration**: If you've set this up, then Headplane will read the `acl_policy_path` key from the configuration file.
### Docker Integration
The Docker integration can be used to automatically reload the configuration or ACLs when they are changed.
In order for this to work, you'll need to pass in the `HEADSCALE_CONTAINER` environment variable.
You'll also need to ensure that `/var/run/docker.sock` is mounted if Headplane is running in a container.
This should be either the name or ID of the Headscale container (you can retrieve this using `docker ps`).
If the other integrations aren't setup, then Headplane will automatically disable the Docker integration.
## Deployment
Requirements:
- Headscale 0.23 alpha or later
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
- Headscale needs to be running in a docker container
Here's a good Docker Compose example:
```yaml
version: '3.8'
services:
headscale:
image: 'headscale/headscale:0.23.0-alpha5'
container_name: 'headscale'
restart: 'unless-stopped'
command: 'serve'
volumes:
- './data:/var/lib/headscale'
- './configs:/etc/headscale'
ports:
- '8080:8080'
environment:
TZ: 'America/New_York'
headplane:
container_name: headplane
image: ghcr.io/tale/headplane:latest
restart: unless-stopped
volumes:
- './data:/var/lib/headscale'
- './configs:/etc/headscale'
- '/var/run/docker.sock:/var/run/docker.sock:ro'
ports:
- '3000:3000'
environment:
# This is always required for Headplane to work
COOKIE_SECRET: 'abcdefghijklmnopqrstuvwxyz'
HEADSCALE_CONTAINER: 'headscale'
DISABLE_API_KEY_LOGIN: 'true'
HOST: '0.0.0.0'
PORT: '3000'
# Overrides the configuration file values if they are set in config.yaml
# If you want to share the same OIDC configuration you do not need this
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.
> It explains what each variable does, how to configure them, and what the default values are.
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.
## Configuration Scenarios
Since the configuration is fairly modular you can have a variety of different setups.<br>
Here are a few examples to inspire you and show you what can work and what can't:
#### Full Integration
Headscale runs in a container, Headplane can run in either a container or natively.
Headplane is able to manage the configuration file and ACLs that Headscale uses.
When changes happen, the Docker integration will automatically reload the configuration and ACLs.
> Note that the full integration currently isn't possible if Headscale isn't running in a container.
#### Configuration Only
Headscale and Headplane can either run in containers or natively.
Headplane is able to manage the configuration file and ACLs that Headscale uses.
When changes are made, Headscale will need to be manually restarted to apply the changes.
#### ACL Only
Headscale and Headplane can either run in containers or natively.
Headplane is able to manage the ACLs that Headscale uses.
When changes are made, Headscale will need to be sent a `SIGHUP` to reload the ACLs.
In this scenario, Headplane does not have access to the configuration file.
#### Read-Only Configuration or ACLs
If the configuration or ACLs are read-only, Headplane will not be able to manage them.
Instead you'll only be able to view the configurations on the UI and need to edit them manually.
#### No Integration
If no integration is setup, Headplane will not be able to manage the configuration or ACLs.
This is the simplest setup by far, however it also heavily reduces the capabilities of Headplane.
-54
View File
@@ -1,54 +0,0 @@
# Basic Integration
The basic integration is not able to offer advanced features such as:
- Automatic management of Access Control Lists (ACLs)
- Management of DNS settings for your tailnet
- Management of the Headscale configuration
In order to support these features please refer to the [Advanced Integration](./docs/Advanced-Integration.md) guide.
Note that in order to use this deployment strategy you need to run Headscale in a Docker container.
## Deployment
Requirements:
- Headscale 0.23 alpha or later
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
Headplane is currently best run in a Docker container due to the easy configuration.
Here's a very basic `docker-compose.yaml` file that utilizes each configuration variable.
```yaml
version: '3.8'
services:
headplane:
container_name: headplane
image: ghcr.io/tale/headplane:latest
restart: unless-stopped
ports:
- '3000:3000'
environment:
HEADSCALE_URL: 'http://headscale:8080'
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'
```
> For a breakdown of each configuration variable, please refer to the [Configuration](/docs/Configuration.md) guide.
> It explains what each variable does, how to configure them, and what the default values are.
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`).
-47
View File
@@ -1,47 +0,0 @@
# Configuration
You can configure Headplane using environment variables.
#### Required Variables
- **`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`, 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
If you want to use OpenID Connect for SSO, you'll need to provide these variables.
Headplane will utilize the expiry of your tokens to determine the expiry of the session.
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.
Keep in mind that the recommended deployment would be putting Headplane behind /admin on a reverse proxy.
If you use a different domain than the Headscale server, you'll need to make sure that Headscale responds with CORS headers.
```yaml
- client_id: 'headscale'
client_name: 'Headscale and Headplane'
public: false
authorization_policy: 'two_factor'
redirect_uris:
- 'https://headscale.example.com/oidc/callback'
- 'https://headscale.example.com/admin/oidc/callback'
scopes:
- 'openid'
- 'profile'
- 'email'
userinfo_signed_response_alg: 'none'
client_secret: 'my_super_secret_client_secret'
```
+4268
View File
File diff suppressed because it is too large Load Diff
+26 -49
View File
@@ -1,59 +1,36 @@
{
"name": "headplane",
"version": "0.0.1",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"build": "remix vite:build",
"dev": "remix vite:dev",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/server/index.js",
"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",
"@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",
"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"
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch"
},
"devDependencies": {
"@remix-run/dev": "^2.8.1",
"@types/react": "^18.2.20",
"@types/react-dom": "^18.2.7",
"autoprefixer": "^10.4.19",
"eslint": "^8.57.0",
"eslint-config-tale": "^1.0.16",
"postcss": "^8.4.38",
"@sveltejs/adapter-auto": "^3.0.0",
"@sveltejs/kit": "^2.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0",
"@tabler/icons-svelte": "^3.1.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"autoprefixer": "^10.4.18",
"postcss": "^8.4.35",
"postcss-load-config": "^5.0.2",
"svelte": "^4.2.7",
"svelte-check": "^3.6.0",
"tailwindcss": "^3.4.1",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.1.6",
"vite": "^5.1.0",
"vite-tsconfig-paths": "^4.2.1"
"tslib": "^2.4.1",
"typescript": "^5.0.0",
"vite": "^5.0.3"
},
"engines": {
"node": ">=18.0.0"
"type": "module",
"dependencies": {
"@tanstack/svelte-query": "^5.28.4",
"@zerodevx/svelte-toast": "^0.9.5",
"clsx": "^2.1.0",
"oauth4webapi": "^2.10.3"
}
}
-8325
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

+22
View File
@@ -0,0 +1,22 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
--toastContainerBottom: 1.5rem;
--toastContainerRight: 2rem;
--toastContainerLeft: auto;
--toastContainerTop: auto;
--toastBorderRadius: 0.5rem;
--toastBackground: #1f2937;
--toastMsgPadding: 0.5rem 0.75rem;
}
._toastBtn {
display: none !important;
}
._toastBar {
display: none !important;
}
+14
View File
@@ -0,0 +1,14 @@
declare global {
namespace App {
// interface Error {}
interface Locals {
apiKey: string;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export { };
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" class="dark:bg-zinc-950 dark:text-white">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+12
View File
@@ -0,0 +1,12 @@
import { decryptCookie } from '$lib/crypto';
import type { Handle } from '@sveltejs/kit';
export const handle: Handle = async ({ event, resolve }) => {
const cookie = event.cookies.get('hs_api_key');
if (cookie) {
const key = await decryptCookie(cookie);
event.locals.apiKey = key;
}
const response = await resolve(event);
return response;
};
+10
View File
@@ -0,0 +1,10 @@
import { post } from ".";
export async function generateApiKey(exp: number, key: string) {
const expDate = new Date(exp * 1000).toISOString();
const response = await post<{ apiKey: string }>('v1/apikey', key, {
expiration: expDate
});
return response.apiKey;
}
+36
View File
@@ -0,0 +1,36 @@
import { env } from '$env/dynamic/public'
export async function pull<T>(url: string, key: string) {
const prefix = env.PUBLIC_HEADSCALE_URL
const res = await fetch(`${prefix}/api/${url}`, {
headers: {
'Authorization': `Bearer ${key}`
}
})
if (!res.ok) {
throw new Error(res.statusText)
}
return res.json() as Promise<T>
}
export async function post<T>(url: string, key: string, body?: any) {
const prefix = env.PUBLIC_HEADSCALE_URL
const res = await fetch(`${prefix}/api/${url}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
headers: {
'Authorization': `Bearer ${key}`
}
})
if (!res.ok) {
throw new Error(await res.text())
}
return res.json() as Promise<T>
}
export * from './apiKey'
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import { IconCopy } from "@tabler/icons-svelte";
import { toast } from "@zerodevx/svelte-toast";
import clsx from "clsx";
export let key: string;
export let value: string;
export let copyable = false;
</script>
<dl class="flex gap-1 text-sm">
<dt
class="w-1/4 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300"
>
{key}
</dt>
{#if copyable}
<button
class="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
on:click={() => {
navigator.clipboard.writeText(value);
toast.push("Copied " + key);
}}
>
<dd class="min-w-0 truncate px-2 py-1">
{value}
</dd>
<IconCopy
stroke={1}
size={24}
class="text-gray-600 dark:text-gray-200 pr-2"
/>
</button>
{:else}
<dd class="min-w-0 truncate px-2 py-1">
{value}
</dd>
{/if}
</dl>
+22
View File
@@ -0,0 +1,22 @@
<script lang="ts">
import { page } from "$app/stores";
import { base } from "$app/paths";
import clsx from "clsx";
export let to: string;
export let name: string;
export let icon: any;
</script>
<a
href={`${base}${to}`}
class={clsx(
"flex items-center gap-x-2 p-2 border-b-2 text-sm",
$page.url.pathname.startsWith(`${base}${to}`)
? "border-white"
: "border-transparent",
)}
>
<svelte:component this={icon} stroke={1} size={16} />
{name}
</a>
+87
View File
@@ -0,0 +1,87 @@
import { env } from "$env/dynamic/private"
export async function encryptCookie(cookieValue: string) {
const password = env.COOKIE_SECRET;
const salt = crypto.getRandomValues(new Uint8Array(16)); // Generate a random salt
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
{ name: "PBKDF2" },
false,
["deriveKey"]
);
const derivedKey = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-CBC", length: 256 },
false,
["encrypt"]
);
const iv = crypto.getRandomValues(new Uint8Array(16));
const encrypted = await crypto.subtle.encrypt(
{
name: "AES-CBC",
iv: iv,
},
derivedKey,
new TextEncoder().encode(cookieValue)
);
const combinedData = new Uint8Array(salt.byteLength + iv.byteLength + encrypted.byteLength);
combinedData.set(salt, 0);
combinedData.set(iv, salt.byteLength);
combinedData.set(new Uint8Array(encrypted), salt.byteLength + iv.byteLength);
const numberArray = Array.from(new Uint8Array(combinedData));
return btoa(String.fromCharCode.apply(null, numberArray));
}
export async function decryptCookie(encryptedCookie: string) {
const password = env.COOKIE_SECRET;
const encryptedData = Uint8Array.from(atob(encryptedCookie), c => c.charCodeAt(0));
const salt = encryptedData.slice(0, 16);
const iv = encryptedData.slice(16, 32);
const encrypted = encryptedData.slice(32);
const keyMaterial = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(password),
{ name: "PBKDF2" },
false,
["deriveKey"]
);
const derivedKey = await crypto.subtle.deriveKey(
{
name: "PBKDF2",
salt: salt,
iterations: 100000,
hash: "SHA-256",
},
keyMaterial,
{ name: "AES-CBC", length: 256 },
false,
["decrypt"]
);
const decrypted = await crypto.subtle.decrypt(
{
name: "AES-CBC",
iv: iv,
},
derivedKey,
encrypted
);
return new TextDecoder().decode(decrypted);
}
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
@@ -1,4 +1,4 @@
import type { User } from './User'
import type { User } from "./User";
export type Machine = {
id: string;
@@ -12,17 +12,18 @@ export type Machine = {
lastSeen: Date;
expiry: Date;
preAuthKey?: unknown; // TODO
createdAt: Date;
registerMethod: 'REGISTER_METHOD_UNSPECIFIED'
| 'REGISTER_METHOD_AUTH_KEY'
| 'REGISTER_METHOD_CLI'
| 'REGISTER_METHOD_OIDC';
| 'REGISTER_METHOD_OIDC'
forcedTags: string[];
invalidTags: string[];
validTags: string[];
givenName: string;
online: boolean;
online: boolean
}
@@ -1,4 +1,4 @@
import type { Machine } from './Machine'
import type { Machine } from "./Machine";
export type Route = {
id: string;
@@ -1,4 +1,3 @@
export * from './Key'
export * from './Machine'
export * from './Route'
export * from './User'
+25
View File
@@ -0,0 +1,25 @@
<script lang="ts">
import {
IconBrandFlightradar24,
IconServer,
IconUsers,
} from "@tabler/icons-svelte";
import Link from "$lib/components/Link.svelte";
</script>
<header class="bg-gray-800 text-white mb-16">
<nav class="container mx-auto">
<div class="flex items-center gap-x-2 mb-8 pt-4">
<IconBrandFlightradar24 stroke={1} size={24} />
<h1 class="text-2xl">Headplane</h1>
</div>
<div class="flex items-center gap-x-4">
<Link to="/machines" name="Machines" icon={IconServer} />
<Link to="/users" name="Users" icon={IconUsers} />
</div>
</nav>
</header>
<main class="container mx-auto">
<slot />
</main>
+107
View File
@@ -0,0 +1,107 @@
<script lang="ts">
import { IconCircleFilled, IconCopy } from "@tabler/icons-svelte";
import type { PageData } from "./$types";
import { toast } from "@zerodevx/svelte-toast";
import type { Machine } from "$lib/types";
import clsx from "clsx";
import { createQuery } from "@tanstack/svelte-query";
import { pull } from "$lib/api";
export let data: PageData;
const query = createQuery({
queryKey: ["machines"],
queryFn: async () => {
const apiData = await pull<{ nodes: Machine[] }>(
"v1/node",
data.apiKey,
);
return apiData.nodes;
},
});
</script>
<svelte:head>
<title>Machines</title>
</svelte:head>
{#if $query.isLoading}
<p>Loading...</p>
{:else if $query.isError}
<p>Error: {$query.error.message}</p>
{:else if $query.isSuccess}
<table class="table-auto w-full rounded-lg">
<thead>
<tr class="text-left">
<th class="pl-4">Name</th>
<th>IP Addresses</th>
<th>Last Seen</th>
</tr>
</thead>
<tbody class="divide-y divide-zinc-200 dark:divide-zinc-700">
{#each $query.data as machine}
<tr class="hover:bg-zinc-100 dark:hover:bg-zinc-800">
<td class="pt-2 pb-4 pl-4">
<a href={`machines/${machine.id}`}>
<h1>{machine.givenName}</h1>
<span
class="text-sm font-mono text-gray-500 dark:text-gray-400"
>{machine.name}</span
>
</a>
</td>
<td
class="pt-2 pb-4 font-mono text-gray-600 dark:text-gray-300"
>
{#each machine.ipAddresses as ip, i}
<span
class={clsx(
"flex items-center gap-x-1",
i > 0 && "text-sm text-gray-500",
)}
>
{ip}
<button
class="focus:outline-none"
on:click={() => {
navigator.clipboard.writeText(ip);
toast.push("Copied IP address");
}}
>
<IconCopy
stroke={1}
size={16}
class="text-gray-400 dark:text-gray-500"
/>
</button>
</span>
{/each}
</td>
<td>
<span
class="flex items-center gap-x-1 text-sm text-gray-500 dark:text-gray-400"
>
<IconCircleFilled
stroke={1}
size={24}
class={clsx(
"w-4 h-4",
machine.online
? "text-green-700 dark:text-green-400"
: "text-gray-300 dark:text-gray-500",
)}
/>
<p>
{machine.online
? "Connected"
: new Date(
machine.lastSeen,
).toLocaleString()}
</p>
</span>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
+19
View File
@@ -0,0 +1,19 @@
import { pull } from "$lib/api";
import type { Machine } from "$lib/types";
import type { PageLoad } from './$types';
export async function load({ parent }: Parameters<PageLoad>[0]) {
const { queryClient, apiKey } = await parent();
await queryClient.prefetchQuery({
queryKey: ['machines'],
queryFn: async () => {
const data = await pull<{ nodes: Machine[] }>('v1/node', apiKey);
return data.nodes;
},
});
return {
apiKey,
};
}
@@ -0,0 +1,77 @@
<script lang="ts">
import type { PageData } from "./$types";
import { createQuery } from "@tanstack/svelte-query";
import { pull } from "$lib/api";
import type { Machine } from "$lib/types";
import { IconCircleFilled } from "@tabler/icons-svelte";
import Attribute from "$lib/components/Attribute.svelte";
import clsx from "clsx";
export let data: PageData;
const query = createQuery({
queryKey: [`machines/${data.id}`],
queryFn: async () => {
const response = await pull<{ node: Machine }>(
`v1/node/${data.id}`,
data.apiKey,
);
return response.node;
},
});
</script>
<svelte:head>
<title>
{$query.isSuccess ? `${$query.data.givenName} - Machines` : "Machines"}
</title>
</svelte:head>
{#if $query.isLoading}
<p>Loading...</p>
{:else if $query.isError}
<p>Error: {$query.error.message}</p>
{:else if $query.isSuccess}
<div>
<span class="flex items-baseline gap-x-4 text-sm mb-4">
<h1 class="text-2xl font-bold">
{$query.data.givenName}
</h1>
<IconCircleFilled
stroke={1}
size={24}
class={clsx(
"w-4 h-4",
$query.data.online ? "text-green-700" : "text-gray-300",
)}
/>
</span>
<div class="p-4 md:p-6 border dark:border-zinc-700 rounded-lg">
<Attribute key="Creator" value={$query.data.user.name} />
<Attribute key="Node ID" value={$query.data.id} />
<Attribute key="Node Name" value={$query.data.givenName} />
<Attribute key="Hostname" value={$query.data.name} />
<Attribute
key="Node Key"
value={$query.data.nodeKey}
copyable={true}
/>
<Attribute
key="Created"
value={new Date($query.data.createdAt).toLocaleString()}
/>
<Attribute
key="Last Seen"
value={new Date($query.data.lastSeen).toLocaleString()}
/>
<Attribute
key="Expiry"
value={new Date($query.data.expiry).toLocaleString()}
/>
<Attribute
key="Domain"
value={`${$query.data.givenName}.${$query.data.user.name}.ts.net`}
copyable={true}
/>
</div>
</div>
{/if}
@@ -0,0 +1,20 @@
import { pull } from "$lib/api";
import type { Machine } from "$lib/types";
import type { PageLoad } from './$types';
export async function load({ parent, params }: Parameters<PageLoad>[0]) {
const { queryClient, apiKey } = await parent();
await queryClient.prefetchQuery({
queryKey: [`machines/${params.id}`],
queryFn: async () => {
const data = await pull<{ node: Machine }>(`v1/node/${params.id}`, apiKey);
return data.node;
},
});
return {
id: params.id,
apiKey,
}
}
+17
View File
@@ -0,0 +1,17 @@
import { redirect } from "@sveltejs/kit";
import type { LayoutServerLoad } from './$types';
import { base } from "$app/paths";
export async function load({ url, locals }: Parameters<LayoutServerLoad>[0]) {
if (url.pathname === base) {
redirect(307, `${base}/machines`);
}
if (!locals.apiKey && url.pathname !== `${base}/login`) {
redirect(307, `${base}/login`);
}
return {
apiKey: locals.apiKey,
};
}
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
import "../app.css";
import { SvelteToast } from "@zerodevx/svelte-toast";
import {
type QueryClient,
QueryClientProvider,
} from "@tanstack/svelte-query";
type Data = {
queryClient: QueryClient;
};
export let data: Data;
</script>
<QueryClientProvider client={data.queryClient}>
<slot />
</QueryClientProvider>
<SvelteToast
options={{
duration: 2000,
initial: 2000,
dismissable: false,
reversed: true,
intro: { y: "100%" },
}}
/>
+19
View File
@@ -0,0 +1,19 @@
import { browser } from '$app/environment'
import { QueryClient } from '@tanstack/svelte-query'
import type { LayoutLoadEvent } from './$types'
export async function load({ data }: LayoutLoadEvent) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
enabled: browser,
refetchInterval: 1000
},
},
})
return {
queryClient,
apiKey: data.apiKey,
}
}
+38
View File
@@ -0,0 +1,38 @@
<script lang="ts">
import { base } from "$app/paths";
</script>
<svelte:head>
<title>Login</title>
</svelte:head>
<div class="flex min-h-screen items-center justify-center">
<div class="w-1/3 border p-4 rounded-lg">
<h1 class="text-2xl">Login</h1>
<p class="text-sm text-gray-500 mt-8 mb-4">
Enter an API key to authenticate with Headplane. You can generate
one by running
<code class="bg-gray-100 p-1 rounded-md">
headscale apikeys create
</code>
in your terminal.
</p>
<input
type="text"
id="api-key"
class="border rounded-md p-2 w-full"
placeholder="API Key"
/>
<div class="flex items-center gap-x-2 py-2">
<hr class="flex-1" />
<span class="text-gray-500">or</span>
<hr class="flex-1" />
</div>
<a href={`${base}/oidc/start`}>
<button class="bg-blue-500 text-white rounded-md p-2 w-full">
Login with SSO
</button>
</a>
</div>
</div>
+84
View File
@@ -0,0 +1,84 @@
import { json, redirect } from '@sveltejs/kit'
import * as oauth from 'oauth4webapi'
import type { RequestHandler } from './$types'
import { generateApiKey } from '$lib/api'
import { env as publicEnv } from '$env/dynamic/public'
import { base } from "$app/paths";
import { encryptCookie } from '$lib/crypto'
import { env } from '$env/dynamic/private'
export async function GET({ url, cookies }: Parameters<RequestHandler>[0]) {
const issuer = new URL(env.OIDC_ISSUER)
const client = {
client_id: env.OIDC_CLIENT_ID,
client_secret: env.OIDC_CLIENT_SECRET,
token_endpoint_auth_method: 'client_secret_basic',
} satisfies oauth.Client
const response = await oauth.discoveryRequest(issuer, { algorithm: 'oidc' })
const res = await oauth.processDiscoveryResponse(issuer, response)
if (!res.authorization_endpoint) {
return json({ status: 'Error', message: "No authorization endpoint found" }, {
status: 400,
})
}
const oidc = cookies.get('oidc_state')
if (!oidc) {
return json({ status: 'Error', message: "No state found" }, {
status: 400,
})
}
const [state, nonce, verifier] = oidc.split(':')
const params = oauth.validateAuthResponse(res, client, url, state)
if (oauth.isOAuth2Error(params)) {
console.error(params)
return json({ status: 'Error', message: "Invalid response" }, {
status: 400,
})
}
const callback = new URL(`${base}/oidc/callback`, url.origin)
const token_response = await oauth.authorizationCodeGrantRequest(
res,
client,
params,
callback.href,
verifier
)
const challenges = oauth.parseWwwAuthenticateChallenges(token_response)
if (challenges) {
return json({ status: 'Error', message: "Invalid response", challenges }, {
status: 401,
})
}
const result = await oauth.processAuthorizationCodeOpenIDResponse(res, client, token_response, nonce)
if (oauth.isOAuth2Error(result)) {
return json({ status: 'Error', message: "Invalid response", result }, {
status: 400,
})
}
const claims = oauth.getValidatedIdTokenClaims(result)
// Generate an API key for the user that expires in claims.exp
const key = await generateApiKey(claims.exp, env.API_KEY);
const value = await encryptCookie(key);
cookies.set('hs_api_key', value, {
path: base,
expires: new Date(claims.exp * 1000),
sameSite: 'lax',
httpOnly: true,
});
cookies.delete('oidc_state', {
path: `${base}/oidc`
});
return redirect(307, `${base}/machines`);
}
+42
View File
@@ -0,0 +1,42 @@
import { json, redirect } from '@sveltejs/kit'
import * as oauth from 'oauth4webapi'
import type { RequestHandler } from './$types'
import { env } from '$env/dynamic/private'
import { base } from '$app/paths'
export async function GET({ url, cookies }: Parameters<RequestHandler>[0]) {
const issuer = new URL(env.OIDC_ISSUER)
const client = {
client_id: env.OIDC_CLIENT_ID,
token_endpoint_auth_method: 'client_secret_basic',
} satisfies oauth.Client
const response = await oauth.discoveryRequest(issuer)
const res = await oauth.processDiscoveryResponse(issuer, response)
if (!res.authorization_endpoint) {
return json({ status: "Error", message: "No authorization endpoint found" }, {
status: 400,
})
}
const state = oauth.generateRandomState()
const nonce = oauth.generateRandomNonce()
const verifier = oauth.generateRandomCodeVerifier()
const challenge = await oauth.calculatePKCECodeChallenge(verifier)
const callback = new URL(`${base}/oidc/callback`, url.origin)
const auth_url = new URL(res.authorization_endpoint)
auth_url.searchParams.set('client_id', client.client_id)
auth_url.searchParams.set('response_type', 'code')
auth_url.searchParams.set('redirect_uri', callback.href)
auth_url.searchParams.set('scope', 'openid profile email')
auth_url.searchParams.set('code_challenge', challenge)
auth_url.searchParams.set('code_challenge_method', 'S256')
auth_url.searchParams.set('state', state)
auth_url.searchParams.set('nonce', nonce)
cookies.set('oidc_state', `${state}:${nonce}:${verifier}`, {
path: base
})
return redirect(302, auth_url.toString())
}
+15
View File
@@ -0,0 +1,15 @@
import adapter from "@sveltejs/adapter-auto";
import { vitePreprocess } from "@sveltejs/vite-plugin-svelte";
/** @type {import('@sveltejs/kit').Config} */
const config = {
preprocess: [vitePreprocess({})],
kit: {
adapter: adapter(),
paths: {
base: '/admin'
}
},
};
export default config;
+9
View File
@@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
const config = {
content: ["./src/**/*.{html,js,svelte,ts}"],
theme: {
extend: {}
},
};
export default config;
-32
View File
@@ -1,32 +0,0 @@
/* 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: {
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: [animate, aria]
} satisfies Config
-71
View File
@@ -1,71 +0,0 @@
{
// Declare static groups of users. Use autogroups for all users or users with a specific role.
"groups": {
"group:admin": ["tale"],
"group:user": ["tale", "arjun"],
"group:proxmox": ["tale", "arjun"]
},
// Define the tags which can be applied to devices and by which users.
"tagOwners": {
"tag:infra": ["group:admin"],
"tag:hyperv": ["group:admin"],
"tag:vm": ["group:admin", "group:proxmox"]
},
"acls": [
{
"action": "accept",
"src": ["tag:vm", "tag:infra"],
"dst": ["tag:vm:*", "tag:infra:*"]
},
//{
// "action": "accept",
// "src": ["autogroup:member"],
// "dst": ["autogroup:internet:*"],
//},
// Proxmox members have access to all traffic from VMs (including SSH)
{
"action": "accept",
"src": ["group:proxmox"],
"dst": ["tag:vm:*"]
},
// Anyone can access VM traffic
{"action": "accept", "src": ["group:user"], "dst": ["tag:vm:*"]},
// Admins get to override any destination restrictions
{
"action": "accept",
"src": ["group:admin"],
"dst": ["*:*"]
},
],
"ssh": [
{
// Any Proxmox members can SSH into VMs
"action": "accept",
"src": ["group:proxmox"],
"dst": ["tag:vm"],
"users": ["*"]
},
{
// Any Admin can SSH into infrastructure
"action": "accept",
"src": ["group:admin"],
"dst": ["tag:infra"],
"users": ["*"]
}
],
// Test access rules every time they're saved.
// "tests": [
// {
// "src": "alice@example.com",
// "accept": ["tag:example"],
// "deny": ["100.101.102.103:443"],
// },
// ],
}
-325
View File
@@ -1,325 +0,0 @@
---
# headscale will look for a configuration file named `config.yaml` (or `config.json`) in the following order:
#
# - `/etc/headscale`
# - `~/.headscale`
# - current working directory
# The url clients will connect to.
# Typically this will be a domain like:
#
# https://myheadscale.example.com:443
#
server_url: http://localhost:8080
# Address to listen to / bind to on the server
#
# For production:
listen_addr: 0.0.0.0:8080
# Address to listen to /metrics, you may want
# to keep this endpoint private to your internal
# network
#
metrics_listen_addr: 127.0.0.1:9090
# Address to listen for gRPC.
# gRPC is used for controlling a headscale server
# remotely with the CLI
# Note: Remote access _only_ works if you have
# valid certificates.
#
# For production:
grpc_listen_addr: 0.0.0.0:50443
# Allow the gRPC admin interface to run in INSECURE
# mode. This is not recommended as the traffic will
# be unencrypted. Only enable if you know what you
# are doing.
grpc_allow_insecure: false
# Private key used to encrypt the traffic between headscale
# and Tailscale clients.
# The private key file will be autogenerated if it's missing.
#
private_key_path: /var/lib/headscale/private.key
# The Noise section includes specific configuration for the
# TS2021 Noise protocol
noise:
# The Noise private key is used to encrypt the
# traffic between headscale and Tailscale clients when
# using the new Noise-based protocol. It must be different
# from the legacy private key.
private_key_path: /var/lib/headscale/noise_private.key
# List of IP prefixes to allocate tailaddresses from.
# Each prefix consists of either an IPv4 or IPv6 address,
# and the associated prefix length, delimited by a slash.
# While this looks like it can take arbitrary values, it
# needs to be within IP ranges supported by the Tailscale
# client.
# IPv6: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#LL81C52-L81C71
# IPv4: https://github.com/tailscale/tailscale/blob/22ebb25e833264f58d7c3f534a8b166894a89536/net/tsaddr/tsaddr.go#L33
prefixes:
v4: 100.64.0.0/10
v6: fd7a:115c:a1e0::/48
# DERP is a relay system that Tailscale uses when a direct
# connection cannot be established.
# https://tailscale.com/blog/how-tailscale-works/#encrypted-tcp-relays-derp
#
# headscale needs a list of DERP servers that can be presented
# to the clients.
derp:
server:
# If enabled, runs the embedded DERP server and merges it into the rest of the DERP config
# The Headscale server_url defined above MUST be using https, DERP requires TLS to be in place
enabled: false
# Region ID to use for the embedded DERP server.
# The local DERP prevails if the region ID collides with other region ID coming from
# the regular DERP config.
region_id: 999
# Region code and name are displayed in the Tailscale UI to identify a DERP region
region_code: "headscale"
region_name: "Headscale Embedded DERP"
# Listens over UDP at the configured address for STUN connections - to help with NAT traversal.
# When the embedded DERP server is enabled stun_listen_addr MUST be defined.
#
# For more details on how this works, check this great article: https://tailscale.com/blog/how-tailscale-works/
stun_listen_addr: "0.0.0.0:3478"
# List of externally available DERP maps encoded in JSON
urls:
- https://controlplane.tailscale.com/derpmap/default
# Locally available DERP map files encoded in YAML
#
# This option is mostly interesting for people hosting
# their own DERP servers:
# https://tailscale.com/kb/1118/custom-derp-servers/
#
# paths:
# - /etc/headscale/derp-example.yaml
paths: []
# If enabled, a worker will be set up to periodically
# refresh the given sources and update the derpmap
# will be set up.
auto_update_enabled: true
# How often should we check for DERP updates?
update_frequency: 24h
# Disables the automatic check for headscale updates on startup
disable_check_updates: false
# Time before an inactive ephemeral node is deleted?
ephemeral_node_inactivity_timeout: 30m
# Period to check for node updates within the tailnet. A value too low will severely affect
# CPU consumption of Headscale. A value too high (over 60s) will cause problems
# for the nodes, as they won't get updates or keep alive messages frequently enough.
# In case of doubts, do not touch the default 10s.
node_update_check_interval: 10s
database:
type: sqlite3
sqlite:
path: '/var/lib/headscale/db.sqlite'
# # Postgres config
# If using a Unix socket to connect to Postgres, set the socket path in the 'host' field and leave 'port' blank.
# db_type: postgres
# db_host: localhost
# db_port: 5432
# db_name: headscale
# db_user: foo
# db_pass: bar
# If other 'sslmode' is required instead of 'require(true)' and 'disabled(false)', set the 'sslmode' you need
# in the 'db_ssl' field. Refers to https://www.postgresql.org/docs/current/libpq-ssl.html Table 34.1.
# db_ssl: false
### TLS configuration
#
## Let's encrypt / ACME
#
# headscale supports automatically requesting and setting up
# TLS for a domain with Let's Encrypt.
#
# URL to ACME directory
acme_url: https://acme-v02.api.letsencrypt.org/directory
# Email to register with ACME provider
acme_email: ""
# Domain name to request a TLS certificate for:
tls_letsencrypt_hostname: ""
# Path to store certificates and metadata needed by
# letsencrypt
# For production:
tls_letsencrypt_cache_dir: /var/lib/headscale/cache
# Type of ACME challenge to use, currently supported types:
# HTTP-01 or TLS-ALPN-01
# See [docs/tls.md](docs/tls.md) for more information
tls_letsencrypt_challenge_type: HTTP-01
# When HTTP-01 challenge is chosen, letsencrypt must set up a
# verification endpoint, and it will be listening on:
# :http = port 80
tls_letsencrypt_listen: ":http"
## Use already defined certificates:
tls_cert_path: ""
tls_key_path: ""
log:
# Output formatting for logs: text or json
format: text
level: info
# Path to a file containg ACL policies.
# ACLs can be defined as YAML or HUJSON.
# https://tailscale.com/kb/1018/acls/
acl_policy_path: /etc/headscale/acl.json
## DNS
#
# headscale supports Tailscale's DNS configuration and MagicDNS.
# Please have a look to their KB to better understand the concepts:
#
# - https://tailscale.com/kb/1054/dns/
# - https://tailscale.com/kb/1081/magicdns/
# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/
#
dns_config:
# Whether to prefer using Headscale provided DNS or use local.
override_local_dns: true
# List of DNS servers to expose to clients.
nameservers:
- 1.1.1.1
- 1.0.0.1
- 2606:4700:4700::1111
- 2606:4700:4700::1001
# NextDNS (see https://tailscale.com/kb/1218/nextdns/).
# "abc123" is example NextDNS ID, replace with yours.
#
# With metadata sharing:
# nameservers:
# - https://dns.nextdns.io/abc123
#
# Without metadata sharing:
# nameservers:
# - 2a07:a8c0::ab:c123
# - 2a07:a8c1::ab:c123
# Split DNS (see https://tailscale.com/kb/1054/dns/),
# list of search domains and the DNS to query for each one.
#
# restricted_nameservers:
# foo.bar.com:
# - 1.1.1.1
# darp.headscale.net:
# - 1.1.1.1
# - 8.8.8.8
# Search domains to inject.
domains: []
# Extra DNS records
# so far only A-records are supported (on the tailscale side)
# See https://github.com/juanfont/headscale/blob/main/docs/dns-records.md#Limitations
# extra_records:
# - name: "grafana.myvpn.example.com"
# type: "A"
# value: "100.64.0.3"
#
# # you can also put it in one line
# - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.3" }
# Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/).
# Only works if there is at least a nameserver defined.
magic_dns: true
# Defines the base domain to create the hostnames for MagicDNS.
# `base_domain` must be a FQDNs, without the trailing dot.
# The FQDN of the hosts will be
# `hostname.user.base_domain` (e.g., _myhost.myuser.example.com_).
base_domain: ts.net
# Unix socket used for the CLI to connect without authentication
# Note: for production you will want to set this to something like:
unix_socket: /var/run/headscale/headscale.sock
unix_socket_permission: "0777"
#
# headscale supports experimental OpenID connect support,
# it is still being tested and might have some bugs, please
# help us test it.
# OpenID Connect
oidc:
only_start_if_oidc_is_available: false
issuer: "https://sso.example.com"
client_id: "headscale"
client_secret: "super_secret_client_secret"
# # Alternatively, set `client_secret_path` to read the secret from the file.
# # It resolves environment variables, making integration to systemd's
# # `LoadCredential` straightforward:
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
# # client_secret and client_secret_path are mutually exclusive.
#
# # The amount of time from a node is authenticated with OpenID until it
# # expires and needs to reauthenticate.
# # Setting the value to "0" will mean no expiry.
expiry: 180d
#
# # Use the expiry from the token received from OpenID when the user logged
# # in, this will typically lead to frequent need to reauthenticate and should
# # only been enabled if you know what you are doing.
# # Note: enabling this will cause `oidc.expiry` to be ignored.
# use_expiry_from_token: false
#
# # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query
# # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".
#
# scope: ["openid", "profile", "email", "custom"]
# extra_params:
# domain_hint: example.com
#
# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
# # authentication request will be rejected.
#
allowed_domains:
- example.com
# # Note: Groups from keycloak have a leading '/'
# allowed_groups:
# - /headscale
# allowed_users:
# - alice@example.com
#
# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
# # This will transform `first-name.last-name@example.com` to the user `first-name.last-name`
# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
# user: `first-name.last-name.example.com`
#
strip_email_domain: true
# Logtail configuration
# Logtail is Tailscales logging and auditing infrastructure, it allows the control panel
# to instruct tailscale nodes to log their activity to a remote server.
logtail:
# Enable logtail for this headscales clients.
# As there is currently no support for overriding the log server in headscale, this is
# disabled by default. Enabling this will make your clients send logs to Tailscale Inc.
enabled: true
# Enabling this option makes devices prefer a random port for WireGuard traffic over the
# default static port 41641. This option is intended as a workaround for some buggy
# firewall devices. See https://tailscale.com/kb/1181/firewalls/ for more information.
randomize_client_port: false

Some files were not shown because too many files have changed in this diff Show More