mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0f8a6f01b | |||
| 3bd5cc99aa | |||
| 937fa6f6fa | |||
| 3ffbabd7fc | |||
| c08203cc76 | |||
| 57f045eaf6 | |||
| faa583bb38 | |||
| 98ea2cb06f | |||
| 868d85bbeb | |||
| dbd9d39da9 | |||
| 72cc9d8974 | |||
| ed50c48965 | |||
| e19dbda5ed | |||
| a72cc1bb1c | |||
| f0e4868252 | |||
| 1a30185047 | |||
| c7e59b137c | |||
| 694b22f205 | |||
| 84855d9d51 | |||
| 6bea3b2a55 | |||
| 2ee1adf142 | |||
| 06d7d1ccad | |||
| 2aae6bfc06 | |||
| 6794b1b2dd | |||
| a6582ef835 | |||
| 3e51e4861d | |||
| 0a12cdb3d6 | |||
| 78698dcbba | |||
| 260fb870e1 | |||
| 2b1e7be193 | |||
| 65a76a7fa2 | |||
| 3d554a2434 | |||
| d57ee2677c | |||
| 2f02ccc362 | |||
| 37a31e30c7 | |||
| e7c6271322 | |||
| 3deec75715 | |||
| 18999357a8 | |||
| e6eba645c4 | |||
| f563335fab | |||
| 6102fabfcb | |||
| 87430ccf7b | |||
| ee42016a67 | |||
| 9f5ac6a9ef | |||
| 7f55ad826c | |||
| 2cc65e8783 | |||
| 5ff09e44d9 | |||
| c6cdcf35eb | |||
| 61ae161496 | |||
| 82f5e17207 | |||
| a63f4e4d52 | |||
| a57e777a6b | |||
| 8205f2b99b | |||
| c95218b8dd | |||
| 769556efda | |||
| ea47bb5fec | |||
| 3abd617a90 | |||
| cbecf85979 | |||
| 0ebadc26bc | |||
| 252e78d618 | |||
| c6930732ee | |||
| f04b17109b | |||
| 0ff9e6fdc3 | |||
| 94174ebcce | |||
| c2fe69ec17 | |||
| b285753b24 | |||
| 8eac733a5d | |||
| 89a7cb5aae | |||
| 39868b5043 | |||
| ca32590e54 | |||
| a846249be1 | |||
| bcf00beb75 | |||
| 6dae5d647a | |||
| d787b8517e | |||
| bdb00b6cd7 | |||
| f1347803a4 | |||
| 6fa27e5d28 | |||
| 78140927ad | |||
| a47fb61549 | |||
| 1d9f4553eb | |||
| b146e4c3a8 | |||
| 381c3d6df4 | |||
| 37f84cfba5 | |||
| 52c8e2c3bc | |||
| 0a8d3e26f4 | |||
| 9342e81733 | |||
| 224621d9a5 | |||
| 5c0b823c12 | |||
| 935e015be9 | |||
| cc1427882f | |||
| 02e5e3e92a | |||
| 196c4763e5 | |||
| 50bcfa9c04 | |||
| 3f65c7e610 | |||
| 4e129d527a | |||
| e216d4171f | |||
| 029f395673 | |||
| 9244d63826 | |||
| c2060ed45a | |||
| 22b6af685a | |||
| 4a1cdaa432 | |||
| b5658750a9 | |||
| abb957c573 | |||
| 8148e242dc | |||
| 27f310fb35 | |||
| a340b3d263 | |||
| 5df9be1b8e | |||
| 358629a93b | |||
| 1019c1ebf0 | |||
| e6e3110753 | |||
| f0c52d8a8b | |||
| c6435ae2b4 | |||
| db9d08b7f3 | |||
| 6d4e6ebce1 | |||
| b129a9b006 | |||
| 4baf3d7ce7 | |||
| 77702c1bae | |||
| cce30bd29c | |||
| 5bb2f7dfa3 | |||
| fe285b4e6d | |||
| 6ec517012e | |||
| 19538ad839 | |||
| c62da81ea7 | |||
| 22c4b9504b | |||
| 1a7fd1fad2 | |||
| 1cf9901d23 | |||
| bef9b6d46d | |||
| 3b1f0ae6f8 | |||
| b8498a9db3 | |||
| ed9cc23020 | |||
| abaf71636e | |||
| b38eb0885f | |||
| 58992efa2e | |||
| 025cc4f6f1 | |||
| 70c42751e3 |
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
|
||||
/.cache
|
||||
/build
|
||||
.env
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
PUBLIC_HEADSCALE_URL=https://tailscale.example.com
|
||||
API_KEY=abcdefghijklmnopqrstuvwxyz
|
||||
ROOT_API_KEY=abcdefghijklmnopqrstuvwxyz
|
||||
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
|
||||
OIDC_CLIENT_ID=headscale
|
||||
OIDC_CLIENT_ID=headscale
|
||||
OIDC_ISSUER=https://sso.example.com
|
||||
OIDC_CLIENT_SECRET=super_secret_client_secret
|
||||
DISABLE_API_KEY_LOGIN=true
|
||||
HEADSCALE_CONTAINER=headscale
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
CONFIG_FILE=/etc/headscale/config.yaml
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
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
|
||||
+2
-7
@@ -1,10 +1,5 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
|
||||
/.cache
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
### 0.1.8 (June 2, 2024)
|
||||
- Built basic functionality for the machine overview page (by machine ID).
|
||||
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
|
||||
- Prevent users from being deleted if they still have ownership of machines.
|
||||
- Fixed some type issues where `Date` was being used instead of `string` for timestamps.
|
||||
|
||||
### 0.1.7 (May 30, 2024)
|
||||
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
|
||||
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
|
||||
- Made the strict configuration loader more lenient to allow for more flexibility.
|
||||
- Added `HEADSCALE_CONFIG_UNSTRICT`=true to revert back to a weaker configuration loader.
|
||||
- Headplane's context now only loads once at start instead of being lazy-loaded.
|
||||
- Improved logging and error propagation so that it's easier to debug issues.
|
||||
|
||||
### 0.1.6 (May 22, 2024)
|
||||
- Added experimental support for advanced integration without Docker.
|
||||
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
|
||||
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
|
||||
|
||||
### 0.1.5 (May 20, 2024)
|
||||
- Robust configuration handling with fallbacks based on the headscale source.
|
||||
- Support for `client_secret_path` on configuration file based OIDC.
|
||||
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
|
||||
- `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works).
|
||||
- Fixed button responders not actually being invoked (should fix the ACL page).
|
||||
|
||||
### 0.1.4 (May 15, 2024)
|
||||
|
||||
- Users can now be created, renamed, and deleted on the users page.
|
||||
- Machines can be dragged between users to change their ownership.
|
||||
- The login page actually respects the `DISABLE_API_KEY_LOGIN` variable.
|
||||
- Implemented some fixes that should stop dialogs from hanging a webpage.
|
||||
- Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues).
|
||||
- Upgrade other dependencies
|
||||
|
||||
### 0.1.3 (May 4, 2024)
|
||||
|
||||
- Switched to a better icon set for the UI.
|
||||
- Support stable scrollbar gutter if supported by the browser.
|
||||
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
|
||||
|
||||
### 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.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g pnpm
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY patches ./patches
|
||||
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" ]
|
||||
@@ -1,21 +1,32 @@
|
||||
# Headplane
|
||||
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale)
|
||||
|
||||
Headscale is a self-hosted version of the Tailscale control server, however, it currently lacks a first-party web UI.
|
||||
This is a relatively tiny 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.
|
||||

|
||||
|
||||
- [ ] 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
|
||||
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:
|
||||
|
||||
- Machine/Node expiry, network routing, name, and owner management
|
||||
- Access Control List (ACL) and tagging configuration
|
||||
- Support for OpenID Connect (OIDC) as a login provider
|
||||
- DNS and *safe* Headscale configuration management
|
||||
|
||||
## Deployment
|
||||
Instructions for deploying this will come soon. It will utilize Docker to support advanced features.
|
||||
> For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
|
||||
|
||||
For fully-featured deployments, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
|
||||
This includes automatic management of ACLs, DNS settings, and Headscale configuration.
|
||||
*This is the closest experience to the Tailscale UI that can be achieved with Headscale and Headplane.*
|
||||
*If you aren't sure which one to pick, we recommend this.*
|
||||
|
||||
If your environment is not able to support the advanced deployment, you can still use the basic deployment.
|
||||
For basic deployments, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
|
||||
It does not include automatic management of ACLs, DNS settings, or the Headscale configuration,
|
||||
instead requiring manual editing and reloading when making changes.
|
||||
|
||||
## Contributing
|
||||
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.
|
||||
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.
|
||||
|
||||
> Copyright (c) 2024 Aarnav Tale
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CopyIcon } from '@primer/octicons-react'
|
||||
|
||||
import { toast } from './Toaster'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
value: string
|
||||
isCopyable?: boolean
|
||||
}
|
||||
|
||||
export default function Attribute({ name, value, isCopyable }: Props) {
|
||||
const canCopy = isCopyable ?? false
|
||||
return (
|
||||
<dl className="flex gap-1 text-sm w-full">
|
||||
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
|
||||
{name}
|
||||
</dt>
|
||||
|
||||
{canCopy
|
||||
? (
|
||||
<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"
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast(`Copied ${name}`)
|
||||
}}
|
||||
>
|
||||
<dd className="min-w-0 truncate px-2 py-1">
|
||||
{value}
|
||||
</dd>
|
||||
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<dd className="min-w-0 truncate px-2 py-1">
|
||||
{value}
|
||||
</dd>
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
import { Button as AriaButton } from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
readonly variant?: 'heavy' | 'light'
|
||||
}
|
||||
|
||||
export default function Button(properties: ButtonProperties) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
properties.variant === 'heavy'
|
||||
? 'bg-main-700 dark:bg-main-800'
|
||||
: 'bg-main-200 dark:bg-main-700/30',
|
||||
properties.variant === 'heavy'
|
||||
? 'hover:bg-main-800 dark:hover:bg-main-700'
|
||||
: 'hover:bg-main-300 dark:hover:bg-main-600/30',
|
||||
properties.variant === 'heavy'
|
||||
? 'text-white'
|
||||
: 'text-ui-700 dark:text-ui-300',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.className,
|
||||
)}
|
||||
// If control is passed, set the state value
|
||||
onPress={properties.control
|
||||
? () => {
|
||||
properties.control?.[1](true)
|
||||
}
|
||||
: properties.onPress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type HTMLProps } from 'react'
|
||||
import { Heading as AriaHeading } from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
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 Properties = HTMLProps<HTMLDivElement> & {
|
||||
variant?: 'raised' | 'flat';
|
||||
}
|
||||
|
||||
function Card(properties: Properties) {
|
||||
return (
|
||||
<div
|
||||
{...properties}
|
||||
className={cn(
|
||||
'w-full max-w-md overflow-hidden rounded-xl p-4',
|
||||
properties.variant === 'flat'
|
||||
? 'bg-transparent shadow-none'
|
||||
: 'bg-ui-50 dark:bg-ui-900 shadow-sm',
|
||||
'border border-ui-200 dark:border-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Card, { Title, Text })
|
||||
@@ -0,0 +1,12 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/* 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',
|
||||
'hover:bg-main-800 dark:hover:bg-main-700',
|
||||
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 })
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AlertIcon } from '@primer/octicons-react'
|
||||
import { isRouteErrorResponse, useRouteError } from '@remix-run/react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Card from './Card'
|
||||
import Code from './Code'
|
||||
|
||||
type Properties = {
|
||||
readonly type?: 'full' | 'embedded';
|
||||
}
|
||||
|
||||
export function ErrorPopup({ type = 'full' }: Properties) {
|
||||
const error = useRouteError()
|
||||
const routing = isRouteErrorResponse(error)
|
||||
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
type === 'embedded'
|
||||
? 'pointer-events-none mt-24'
|
||||
: 'fixed inset-0 h-screen w-screen z-50'
|
||||
)}
|
||||
>
|
||||
<Card>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Card.Title className='text-3xl mb-0'>
|
||||
{routing ? error.status : 'Error'}
|
||||
</Card.Title>
|
||||
<AlertIcon className='w-12 h-12 text-red-500'/>
|
||||
</div>
|
||||
<Card.Text className='mt-4 text-lg'>
|
||||
{routing ? (
|
||||
error.statusText
|
||||
) : (
|
||||
<Code>
|
||||
{message}
|
||||
</Code>
|
||||
)}
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIcon, ServerIcon } from '@primer/octicons-react'
|
||||
import { Form } from '@remix-run/react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
import { HeadplaneContext } from '~/utils/config/headplane'
|
||||
import { type SessionData } from '~/utils/sessions'
|
||||
|
||||
import Menu from './Menu'
|
||||
import TabLink from './TabLink'
|
||||
|
||||
interface Properties {
|
||||
readonly data?: {
|
||||
acl: HeadplaneContext['acl']
|
||||
config: HeadplaneContext['config']
|
||||
user?: SessionData['user']
|
||||
}
|
||||
}
|
||||
|
||||
interface LinkProperties {
|
||||
readonly href: string
|
||||
readonly text: string
|
||||
readonly isMenu?: boolean
|
||||
}
|
||||
|
||||
function Link({ href, text, isMenu }: LinkProperties) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Header({ data }: Properties) {
|
||||
return (
|
||||
<header className="bg-main-700 dark:bg-main-800 text-ui-50">
|
||||
<div className="container flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<PaperAirplaneIcon className="w-6 h-6" />
|
||||
<h1 className="text-2xl">Headplane</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-x-4">
|
||||
<Link href="https://tailscale.com/download" text="Download" />
|
||||
<Link href="https://github.com/tale/headplane" text="GitHub" />
|
||||
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
||||
{data?.user
|
||||
? (
|
||||
<Menu>
|
||||
<Menu.Button className={cn(
|
||||
'rounded-full h-9 w-9',
|
||||
'border border-main-600 dark:border-main-700',
|
||||
'hover:bg-main-600 dark:hover:bg-main-700',
|
||||
)}
|
||||
>
|
||||
<PersonIcon className="h-5 w-5 mt-0.5" />
|
||||
</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-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://tailscale.com/download"
|
||||
text="Download"
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://github.com/tale/headplane"
|
||||
text="GitHub"
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item className="text-right sm:hidden">
|
||||
<Link
|
||||
isMenu
|
||||
href="https://github.com/juanfont/headscale"
|
||||
text="Headscale"
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
: undefined}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="container flex items-center gap-x-4 overflow-x-auto">
|
||||
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
|
||||
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
|
||||
{data?.acl.read
|
||||
? (
|
||||
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
|
||||
)
|
||||
: undefined}
|
||||
{data?.config.read
|
||||
? (
|
||||
<>
|
||||
<TabLink to="/dns" name="DNS" icon={<GlobeIcon className="w-4 h-4" />} />
|
||||
<TabLink to="/settings" name="Settings" icon={<GearIcon className="w-4 h-4" />} />
|
||||
</>
|
||||
)
|
||||
: undefined}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { LinkExternalIcon } from '@primer/octicons-react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
to: string
|
||||
name: string
|
||||
children: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Link({ to, name: alt, children, className }: Props) {
|
||||
return (
|
||||
<a
|
||||
href={to}
|
||||
aria-label={alt}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-x-1',
|
||||
'text-blue-500 hover:text-blue-700',
|
||||
'dark:text-blue-400 dark:hover:text-blue-300',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<LinkExternalIcon className="h-3 w-3" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Menu as AriaMenu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
Popover
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
function Button(properties: Parameters<typeof AriaButton>[0]) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
className={cn(
|
||||
'outline-none',
|
||||
properties.className
|
||||
)}
|
||||
aria-label='Menu'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Items(properties: Parameters<typeof AriaMenu>[0]) {
|
||||
return (
|
||||
<Popover className={cn(
|
||||
'mt-2 rounded-md',
|
||||
'bg-ui-50 dark:bg-ui-800',
|
||||
'overflow-hidden z-50',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:zoom-in-95',
|
||||
'exiting:fade-out exiting:zoom-out-95',
|
||||
'fill-mode-forwards origin-left-right'
|
||||
)}
|
||||
>
|
||||
<AriaMenu
|
||||
{...properties}
|
||||
className={cn(
|
||||
'outline-none',
|
||||
'divide-y divide-ui-200 dark:divide-ui-600',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</AriaMenu>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
}
|
||||
|
||||
function ItemButton(properties: ButtonProperties) {
|
||||
return (
|
||||
<MenuItem className='outline-none'>
|
||||
<AriaButton
|
||||
{...properties}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none text-left',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
aria-label='Menu Dialog'
|
||||
// If control is passed, set the state value
|
||||
onPress={event => {
|
||||
properties.onPress?.(event)
|
||||
properties.control?.[1](true)
|
||||
}}
|
||||
/>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function Item(properties: Parameters<typeof MenuItem>[0]) {
|
||||
return (
|
||||
<MenuItem
|
||||
{...properties}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Menu({ children }: { readonly children: ReactNode }) {
|
||||
return (
|
||||
<MenuTrigger>
|
||||
{children}
|
||||
</MenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Menu, { Button, Item, ItemButton, Items })
|
||||
@@ -0,0 +1,16 @@
|
||||
import { InfoIcon } from '@primer/octicons-react'
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<InfoIcon className='h-6 w-6 text-white'/>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
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] p-[4px] shrink-0',
|
||||
'rounded-full outline-none group-focus-visible:ring-2',
|
||||
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'h-[18px] w-[18px] transform rounded-full',
|
||||
'bg-white transition duration-100 ease-in-out',
|
||||
'translate-x-0 group-selected:translate-x-[100%]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AriaSwitch>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NavLink } from '@remix-run/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type Properties = {
|
||||
readonly name: string;
|
||||
readonly to: string;
|
||||
readonly icon: ReactNode;
|
||||
}
|
||||
|
||||
export default function TabLink({ name, to, icon }: Properties) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
prefetch='intent'
|
||||
className={({ isActive }) => cn(
|
||||
'border-b-2 py-1.5',
|
||||
isActive ? 'border-white' : 'border-transparent'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap',
|
||||
'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md'
|
||||
)}
|
||||
>
|
||||
{icon} {name}
|
||||
</div>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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 })
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
import {
|
||||
Input,
|
||||
TextField as AriaTextField
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
|
||||
readonly label: string;
|
||||
readonly placeholder: string;
|
||||
readonly state?: [string, Dispatch<SetStateAction<string>>];
|
||||
}
|
||||
|
||||
export default function TextField(properties: TextFieldProperties) {
|
||||
return (
|
||||
<AriaTextField
|
||||
{...properties}
|
||||
aria-label={properties.label}
|
||||
className='w-full'
|
||||
>
|
||||
<Input
|
||||
placeholder={properties.placeholder}
|
||||
value={properties.state?.[0]}
|
||||
name={properties.name}
|
||||
className={cn(
|
||||
'block px-2.5 py-1.5 w-full rounded-lg my-1',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'dark:bg-ui-800 dark:text-ui-300',
|
||||
properties.className
|
||||
)}
|
||||
onChange={event => {
|
||||
properties.state?.[1](event.target.value)
|
||||
}}
|
||||
/>
|
||||
</AriaTextField>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { XIcon } from '@primer/octicons-react'
|
||||
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'
|
||||
)}
|
||||
>
|
||||
<XIcon 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ReactNode } from 'react'
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Tooltip as AriaTooltip,
|
||||
TooltipTrigger,
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function Tooltip({ children }: Props) {
|
||||
return (
|
||||
<TooltipTrigger delay={0}>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function Button(props: Parameters<typeof AriaButton>[0]) {
|
||||
return (
|
||||
<AriaButton {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function Body({ children, className }: Props) {
|
||||
return (
|
||||
<AriaTooltip className={cn(
|
||||
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
||||
'bg-white dark:bg-ui-900 drop-shadow-sm',
|
||||
'border border-gray-200 dark:border-zinc-700',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</AriaTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Tooltip, { Button, Body })
|
||||
@@ -0,0 +1,62 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
|
||||
import type { AppLoadContext, 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'
|
||||
|
||||
import { loadContext } from './utils/config/headplane'
|
||||
|
||||
await loadContext()
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_loadContext: AppLoadContext,
|
||||
) {
|
||||
const ua = request.headers.get('user-agent')
|
||||
const isBot = ua ? isbot(ua) : false
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={5000}
|
||||
/>,
|
||||
{
|
||||
[isBot ? 'onAllReady' : '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 as Error)
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500
|
||||
if (shellRendered) {
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
setTimeout(abort, 5000)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { access, constants } from 'node:fs/promises'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
import { Client } from 'undici'
|
||||
|
||||
import { HeadscaleError, pull } from '~/utils/headscale'
|
||||
|
||||
import type { Integration } from '.'
|
||||
|
||||
// Integration name
|
||||
const name = 'Docker'
|
||||
|
||||
let url: URL | undefined
|
||||
let container: string | undefined
|
||||
|
||||
async function preflight() {
|
||||
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
|
||||
|
||||
try {
|
||||
url = new URL(path)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
// The API is available as an HTTP endpoint
|
||||
if (url.protocol === 'tcp:') {
|
||||
url.protocol = 'http:'
|
||||
}
|
||||
|
||||
// Check if the socket is accessible
|
||||
if (url.protocol === 'unix:') {
|
||||
try {
|
||||
await access(path, constants.R_OK)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (url.protocol === 'http:') {
|
||||
try {
|
||||
await fetch(new URL('/v1.30/version', url).href)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'unix:') {
|
||||
return false
|
||||
}
|
||||
|
||||
container = process.env.HEADSCALE_CONTAINER
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (!container || container.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
async function sighup() {
|
||||
if (!url || !container) {
|
||||
return
|
||||
}
|
||||
|
||||
// Supports the DOCKER_SOCK environment variable
|
||||
const client = url.protocol === 'unix:'
|
||||
? new Client('http://localhost', {
|
||||
socketPath: url.href,
|
||||
})
|
||||
: new Client(url.href)
|
||||
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
async function restart() {
|
||||
if (!url || !container) {
|
||||
return
|
||||
}
|
||||
|
||||
// Supports the DOCKER_SOCK environment variable
|
||||
const client = url.protocol === 'unix:'
|
||||
? new Client('http://localhost', {
|
||||
socketPath: url.href,
|
||||
})
|
||||
: new Client(url.href)
|
||||
|
||||
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
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, no-constant-condition
|
||||
while (true) {
|
||||
try {
|
||||
await pull('v1', '')
|
||||
return
|
||||
} catch (error) {
|
||||
if (error instanceof HeadscaleError && error.status === 401) {
|
||||
break
|
||||
}
|
||||
|
||||
if (attempts > 10) {
|
||||
throw new Error('Headscale did not restart in time')
|
||||
}
|
||||
|
||||
attempts++
|
||||
await setTimeout(1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default { name, preflight, sighup, restart } satisfies Integration
|
||||
@@ -0,0 +1,58 @@
|
||||
import docker from './docker'
|
||||
import proc from './proc'
|
||||
|
||||
export interface Integration {
|
||||
name: string
|
||||
preflight: () => Promise<boolean>
|
||||
sighup?: () => Promise<void>
|
||||
restart?: () => Promise<void>
|
||||
}
|
||||
|
||||
// Because we previously supported the Docker integration by
|
||||
// checking for the HEADSCALE_CONTAINER variable, we need to
|
||||
// check for it here as well.
|
||||
//
|
||||
// This ensures that when people upgrade from older versions
|
||||
// of Headplane, they don't explicitly need to define the new
|
||||
// HEADSCALE_INTEGRATION variable that is needed to configure
|
||||
// an integration.
|
||||
export async function checkIntegration() {
|
||||
let integration = process.env.HEADSCALE_INTEGRATION
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
|
||||
// Old HEADSCALE_CONTAINER variable upgrade path
|
||||
if (!integration && process.env.HEADSCALE_CONTAINER) {
|
||||
integration = 'docker'
|
||||
}
|
||||
|
||||
if (!integration) {
|
||||
console.log('Running Headplane without any integrations')
|
||||
return
|
||||
}
|
||||
|
||||
let module: Integration | undefined
|
||||
try {
|
||||
module = getIntegration(integration)
|
||||
await module.preflight()
|
||||
} catch (error) {
|
||||
console.error('Failed to load integration', error)
|
||||
return
|
||||
}
|
||||
|
||||
return module
|
||||
}
|
||||
|
||||
function getIntegration(name: string) {
|
||||
switch (name) {
|
||||
case 'docker': {
|
||||
return docker
|
||||
}
|
||||
case 'proc': {
|
||||
return proc
|
||||
}
|
||||
default: {
|
||||
throw new Error(`Unknown integration: ${name}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { access, constants, readdir, readFile } from 'node:fs/promises'
|
||||
import { platform } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { kill } from 'node:process'
|
||||
|
||||
import type { Integration } from '.'
|
||||
|
||||
// Integration name
|
||||
const name = 'Native Linux (/proc)'
|
||||
|
||||
// Check if we have a /proc and if it's readable
|
||||
async function preflight() {
|
||||
if (platform() !== 'linux') {
|
||||
return false
|
||||
}
|
||||
|
||||
const dir = resolve('/proc')
|
||||
try {
|
||||
await access(dir, constants.R_OK)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to access /proc', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function findPid() {
|
||||
const dirs = await readdir('/proc')
|
||||
|
||||
const promises = dirs.map(async (dir) => {
|
||||
const pid = Number.parseInt(dir, 10)
|
||||
|
||||
if (Number.isNaN(pid)) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = join('/proc', dir, 'cmdline')
|
||||
try {
|
||||
const data = await readFile(path, 'utf8')
|
||||
if (data.includes('headscale')) {
|
||||
return pid
|
||||
}
|
||||
} catch {}
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(promises)
|
||||
const pids = []
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
pids.push(result.value)
|
||||
}
|
||||
}
|
||||
|
||||
if (pids.length > 1) {
|
||||
console.warn('Found multiple Headscale processes', pids)
|
||||
console.log('Disabling the /proc integration')
|
||||
return
|
||||
}
|
||||
|
||||
if (pids.length === 0) {
|
||||
console.warn('Could not find Headscale process')
|
||||
console.log('Disabling the /proc integration')
|
||||
return
|
||||
}
|
||||
|
||||
return pids[0]
|
||||
}
|
||||
|
||||
async function sighup() {
|
||||
const pid = await findPid()
|
||||
if (!pid) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
kill(pid, 'SIGHUP')
|
||||
} catch (error) {
|
||||
console.error('Failed to send SIGHUP to Headscale', error)
|
||||
}
|
||||
}
|
||||
|
||||
export default { name, preflight, sighup } satisfies Integration
|
||||
@@ -0,0 +1,48 @@
|
||||
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'
|
||||
|
||||
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 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-ui-950 dark:text-ui-50">
|
||||
{children}
|
||||
<Toaster />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ErrorPopup />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
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'
|
||||
|
||||
interface 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="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
|
||||
onPress={() => {
|
||||
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
|
||||
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl}
|
||||
onPress={() => {
|
||||
setAcl(data.currentAcl)
|
||||
}}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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
|
||||
variant='heavy'
|
||||
className='mr-2'
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
) : undefined}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
||||
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 Link from '~/components/Link'
|
||||
import Notice from '~/components/Notice'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
|
||||
import Editor from './editor'
|
||||
import Fallback from './fallback'
|
||||
|
||||
export async function loader() {
|
||||
const context = await loadContext()
|
||||
if (!context.acl.read) {
|
||||
throw new Error('No ACL configuration is available')
|
||||
}
|
||||
|
||||
const { data, type } = await loadAcl()
|
||||
return {
|
||||
hasAclWrite: context.acl.write,
|
||||
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 loadContext()
|
||||
if (!context.acl.write) {
|
||||
return json({ success: false }, {
|
||||
status: 403,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.json() as { acl: string }
|
||||
await patchAcl(data.acl)
|
||||
|
||||
if (context.integration?.sighup) {
|
||||
await context.integration.sighup()
|
||||
}
|
||||
|
||||
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
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1018/acls"
|
||||
name="Tailscale ACL documentation"
|
||||
>
|
||||
Tailscale ACL guide
|
||||
</Link>
|
||||
{' '}
|
||||
and the
|
||||
{' '}
|
||||
<Link
|
||||
to="https://headscale.net/acls"
|
||||
name="Headscale ACL documentation"
|
||||
>
|
||||
Headscale docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
|
||||
<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' : '',
|
||||
)}
|
||||
>
|
||||
<PencilIcon 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',
|
||||
)}
|
||||
>
|
||||
<IssueDraftIcon 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { RepoForkedIcon } from '@primer/octicons-react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Switch from '~/components/Switch'
|
||||
import TextField from '~/components/TextField'
|
||||
import Tooltip from '~/components/Tooltip'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
nameservers: Record<string, string[]>
|
||||
}
|
||||
|
||||
export default function AddNameserver({ nameservers }: Props) {
|
||||
const submit = useSubmit()
|
||||
const [split, setSplit] = useState(false)
|
||||
const [ns, setNs] = useState('')
|
||||
const [domain, setDomain] = useState('')
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>
|
||||
Add nameserver
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Add nameserver
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className="font-semibold">
|
||||
Nameserver
|
||||
</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
Use this IPv4 or IPv6 address to resolve names.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!ns) return
|
||||
|
||||
if (split) {
|
||||
const splitNs: Record<string, string[]> = {}
|
||||
for (const [key, value] of Object.entries(nameservers)) {
|
||||
if (key === 'global') continue
|
||||
splitNs[key] = value
|
||||
}
|
||||
|
||||
if (Object.keys(splitNs).includes(domain)) {
|
||||
splitNs[domain].push(ns)
|
||||
} else {
|
||||
splitNs[domain] = [ns]
|
||||
}
|
||||
|
||||
submit({
|
||||
'dns_config.restricted_nameservers': splitNs,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
} else {
|
||||
const globalNs = nameservers.global
|
||||
globalNs.push(ns)
|
||||
|
||||
submit({
|
||||
'dns_config.nameservers': globalNs,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}
|
||||
|
||||
setNs('')
|
||||
setDomain('')
|
||||
setSplit(false)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="DNS Server"
|
||||
placeholder="1.2.3.4"
|
||||
name="ns"
|
||||
state={[ns, setNs]}
|
||||
className="mt-2 mb-8"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="block">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Dialog.Text className="font-semibold">
|
||||
Restrict to domain
|
||||
</Dialog.Text>
|
||||
<Tooltip>
|
||||
<Tooltip.Button className={cn(
|
||||
'text-xs rounded-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
|
||||
Split DNS
|
||||
</Tooltip.Button>
|
||||
<Tooltip.Body>
|
||||
Only clients that support split DNS
|
||||
(Tailscale v1.8 or later for most platforms)
|
||||
will use this nameserver. Older clients
|
||||
will ignore it.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Dialog.Text className="text-sm">
|
||||
This nameserver will only be used for some domains.
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
label="Split DNS"
|
||||
defaultSelected={split}
|
||||
onChange={() => { setSplit(!split) }}
|
||||
/>
|
||||
</div>
|
||||
{split
|
||||
? (
|
||||
<>
|
||||
<Dialog.Text className="font-semibold mt-8">
|
||||
Domain
|
||||
</Dialog.Text>
|
||||
<TextField
|
||||
label="Domain"
|
||||
placeholder="example.com"
|
||||
name="domain"
|
||||
state={[domain, setDomain]}
|
||||
className="my-2"
|
||||
/>
|
||||
<Dialog.Text className="text-sm">
|
||||
Only single-label or fully-qualified queries
|
||||
matching this suffix should use the nameserver.
|
||||
</Dialog.Text>
|
||||
</>
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Add
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
/* 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 { LockIcon, ThreeBarsIcon } from '@primer/octicons-react'
|
||||
import { type FetcherWithComponents, useFetcher } from '@remix-run/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button, Input } from 'react-aria-components'
|
||||
|
||||
import Spinner from '~/components/Spinner'
|
||||
import TableList from '~/components/TableList'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
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>
|
||||
<LockIcon 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
|
||||
type='text'
|
||||
className='font-mono text-sm bg-transparent w-full mr-2'
|
||||
placeholder='Search Domain'
|
||||
value={newDomain}
|
||||
onChange={event => {
|
||||
setNewDomain(event.target.value)
|
||||
}}
|
||||
/>
|
||||
{fetcher.state === 'idle' ? (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm font-semibold',
|
||||
'text-blue-600 dark:text-blue-400',
|
||||
'hover:text-blue-700 dark:hover:text-blue-300',
|
||||
newDomain.length === 0 && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
isDisabled={newDomain.length === 0}
|
||||
onPress={() => {
|
||||
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={cn(
|
||||
'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 : (
|
||||
<ThreeBarsIcon
|
||||
className='h-4 w-4 text-gray-400 focus:outline-none'
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
)}
|
||||
{domain}
|
||||
</p>
|
||||
{isDrag ? undefined : (
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-red-600 dark:text-red-400',
|
||||
'hover:text-red-700 dark:hover:text-red-300',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Spinner from '~/components/Spinner'
|
||||
|
||||
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}>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { Button } from 'react-aria-components'
|
||||
|
||||
import Link from '~/components/Link'
|
||||
import Switch from '~/components/Switch'
|
||||
import TableList from '~/components/TableList'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import AddNameserver from './dialogs/nameserver'
|
||||
|
||||
interface Props {
|
||||
nameservers: Record<string, string[]>
|
||||
override: boolean
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export default function Nameservers({ nameservers, override, isDisabled }: Props) {
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Set the nameservers used by devices on the Tailnet
|
||||
to resolve DNS queries.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1054/dns"
|
||||
name="Tailscale DNS Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
{Object.keys(nameservers).map(key => (
|
||||
<NameserverList
|
||||
key={key}
|
||||
isGlobal={key === 'global'}
|
||||
isDisabled={isDisabled}
|
||||
nameservers={nameservers[key]}
|
||||
override={override}
|
||||
name={key}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isDisabled
|
||||
? undefined
|
||||
: (
|
||||
<AddNameserver nameservers={nameservers} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ListProps {
|
||||
isGlobal: boolean
|
||||
isDisabled: boolean
|
||||
nameservers: string[]
|
||||
name: string
|
||||
override: boolean
|
||||
}
|
||||
|
||||
function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: ListProps) {
|
||||
const [localOverride, setLocalOverride] = useState(override)
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h2 className="text-md font-medium opacity-80">
|
||||
{isGlobal ? 'Global Nameservers' : name}
|
||||
</h2>
|
||||
{isGlobal
|
||||
? (
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-sm opacity-50">
|
||||
Override local DNS
|
||||
</span>
|
||||
<Switch
|
||||
label="Override local DNS"
|
||||
defaultSelected={localOverride}
|
||||
isDisabled={isDisabled}
|
||||
onChange={() => {
|
||||
submit({
|
||||
'dns_config.override_local_dns': !localOverride,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
|
||||
setLocalOverride(!localOverride)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: undefined}
|
||||
</div>
|
||||
<TableList>
|
||||
{nameservers.map((ns, index) => (
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<TableList.Item key={index}>
|
||||
<p className="font-mono text-sm">{ns}</p>
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-red-600 dark:text-red-400',
|
||||
'hover:text-red-700 dark:hover:text-red-300',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => {
|
||||
if (isGlobal) {
|
||||
submit({
|
||||
'dns_config.nameservers': nameservers
|
||||
.filter((_, i) => i !== index),
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
} else {
|
||||
const key = `dns_config.restricted_nameservers."${name}"`
|
||||
submit({
|
||||
[key]: nameservers
|
||||
.filter((_, i) => i !== index),
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))}
|
||||
</TableList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
/* eslint-disable unicorn/no-keyword-prefix */
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { Input } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
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={cn(
|
||||
'block px-2.5 py-1.5 w-1/2 rounded-lg my-4',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'dark:bg-ui-800 dark:text-ui-300 text-sm',
|
||||
'outline-none'
|
||||
)}
|
||||
type='text'
|
||||
value={name}
|
||||
onFocus={event => {
|
||||
event.target.select()
|
||||
}}
|
||||
/>
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={disabled}>
|
||||
{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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { type ActionFunctionArgs } from '@remix-run/node'
|
||||
import { json, useLoaderData } from '@remix-run/react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Notice from '~/components/Notice'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig, patchConfig } from '~/utils/config/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import Domains from './domains'
|
||||
import MagicModal from './magic'
|
||||
import Nameservers from './nameservers'
|
||||
import RenameModal from './rename'
|
||||
|
||||
// We do not want to expose every config value
|
||||
export async function loader() {
|
||||
const context = await loadContext()
|
||||
if (!context.config.read) {
|
||||
throw new Error('No configuration is available')
|
||||
}
|
||||
|
||||
const config = await loadConfig()
|
||||
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 loadContext()
|
||||
if (!context.config.write) {
|
||||
return json({ success: false }, {
|
||||
status: 403,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.json() as Record<string, unknown>
|
||||
await patchConfig(data)
|
||||
|
||||
if (context.integration?.restart) {
|
||||
await context.integration.restart()
|
||||
}
|
||||
|
||||
return json({ success: true })
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
useLiveData({ interval: 5000 })
|
||||
const data = useLoaderData<typeof loader>()
|
||||
|
||||
const allNs: Record<string, string[]> = {}
|
||||
for (const key of Object.keys(data.splitDns)) {
|
||||
allNs[key] = data.splitDns[key]
|
||||
}
|
||||
|
||||
allNs.global = data.nameservers
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-16 max-w-screen-lg">
|
||||
{data.config.write
|
||||
? undefined
|
||||
: (
|
||||
<Notice>
|
||||
The Headscale configuration is read-only. You cannot make changes to the configuration
|
||||
</Notice>
|
||||
)}
|
||||
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
|
||||
<Nameservers
|
||||
nameservers={allNs}
|
||||
override={data.overrideLocal}
|
||||
isDisabled={!data.config.write}
|
||||
/>
|
||||
|
||||
<Domains
|
||||
baseDomain={data.magicDns ? data.baseDomain : undefined}
|
||||
searchDomains={data.searchDomains}
|
||||
disabled={!data.config.write}
|
||||
/>
|
||||
|
||||
<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">
|
||||
Automatically 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.config.write} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
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, Route } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
import { pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import MenuOptions from './_data.machines._index/menu'
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
const context = await loadContext()
|
||||
let magic: string | undefined
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
if (config.dns_config.magic_dns) {
|
||||
magic = config.dns_config.base_domain
|
||||
}
|
||||
}
|
||||
|
||||
const [machine, routes] = await Promise.all([
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||
])
|
||||
|
||||
return {
|
||||
machine: machine.node,
|
||||
routes: routes.routes.filter(route => route.node.id === params.id),
|
||||
magic,
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { machine, magic, routes } = useLoaderData<typeof loader>()
|
||||
useLiveData({ interval: 1000 })
|
||||
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
const tags = [
|
||||
...machine.forcedTags,
|
||||
...machine.validTags,
|
||||
]
|
||||
|
||||
if (expired) {
|
||||
tags.unshift('Expired')
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-8 text-md">
|
||||
<Link
|
||||
to="/machines"
|
||||
className="font-medium"
|
||||
>
|
||||
All Machines
|
||||
</Link>
|
||||
<span className="mx-2">
|
||||
/
|
||||
</span>
|
||||
{machine.givenName}
|
||||
</p>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="flex items-baseline gap-x-4 text-sm mb-4">
|
||||
<h1 className="text-2xl font-medium">
|
||||
{machine.givenName}
|
||||
</h1>
|
||||
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
|
||||
</span>
|
||||
|
||||
<MenuOptions
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
magic={magic}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 mt-1 mb-8">
|
||||
{tags.map(tag => (
|
||||
<span
|
||||
key={tag}
|
||||
className={cn(
|
||||
'text-xs rounded-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<h2 className="text-xl font-medium mb-4">
|
||||
Machine Details
|
||||
</h2>
|
||||
<Card variant="flat" className="w-full max-w-full">
|
||||
<Attribute name="Creator" value={machine.user.name} />
|
||||
<Attribute name="Node ID" value={machine.id} />
|
||||
<Attribute name="Node Name" value={machine.givenName} />
|
||||
<Attribute name="Hostname" value={machine.name} />
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Node Key"
|
||||
value={machine.nodeKey}
|
||||
/>
|
||||
<Attribute
|
||||
name="Created"
|
||||
value={new Date(machine.createdAt).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Last Seen"
|
||||
value={new Date(machine.lastSeen).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Expiry"
|
||||
value={new Date(machine.expiry).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Domain"
|
||||
value={`${machine.givenName}.${machine.user.name}.ts.net`}
|
||||
/>
|
||||
</Card>
|
||||
<h2 className="text-xl font-medium mb-4 mt-8">
|
||||
Machine Routes
|
||||
</h2>
|
||||
<Card variant="flat" className="w-full max-w-full">
|
||||
{routes.length === 0
|
||||
? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4',
|
||||
'items-center justify-center',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
No routes are advertised on this machine.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: routes.map((route, i) => (
|
||||
<div
|
||||
key={route.id}
|
||||
className={cn(
|
||||
'flex items-center justify-between',
|
||||
routes.length - 1 === i ? 'border-b pb-3 mb-2' : '',
|
||||
'border-ui-100 dark:border-ui-800',
|
||||
)}
|
||||
>
|
||||
<div>
|
||||
<p className="font-mono mb-1">
|
||||
{route.prefix}
|
||||
</p>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
{' '}
|
||||
(Created:
|
||||
{' '}
|
||||
{new Date(route.createdAt).toLocaleString()}
|
||||
)
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="mb-1">
|
||||
{route.enabled ? 'Enabled' : 'Disabled'}
|
||||
</p>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
{route.isPrimary ? 'Primary' : 'Secondary'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Form, useSubmit } 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'
|
||||
|
||||
interface DeleteProps {
|
||||
readonly machine: Machine
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
}
|
||||
|
||||
export default function Delete({ machine, state }: DeleteProps) {
|
||||
const submit = useSubmit()
|
||||
|
||||
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>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Form, useSubmit } 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'
|
||||
|
||||
interface ExpireProps {
|
||||
readonly machine: Machine
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
}
|
||||
|
||||
export default function Expire({ machine, state }: ExpireProps) {
|
||||
const submit = useSubmit()
|
||||
|
||||
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>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import { type Machine } from '~/types'
|
||||
|
||||
interface RenameProps {
|
||||
readonly machine: Machine
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
readonly magic?: string
|
||||
}
|
||||
|
||||
export default function Rename({ machine, state, magic }: RenameProps) {
|
||||
const [name, setName] = useState(machine.givenName)
|
||||
const submit = useSubmit()
|
||||
|
||||
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>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useFetcher } 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'
|
||||
|
||||
interface RoutesProps {
|
||||
readonly machine: Machine
|
||||
readonly routes: Route[]
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
}
|
||||
|
||||
// TODO: Support deleting routes
|
||||
export default function Routes({ machine, routes, state }: RoutesProps) {
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Edit route settings of
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Connect to devices you can't install Tailscale on
|
||||
by advertising IP ranges as subnet routes.
|
||||
</Dialog.Text>
|
||||
<div className={cn(
|
||||
'rounded-lg overflow-y-auto my-2',
|
||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||
'border border-zinc-200 dark:border-zinc-700',
|
||||
)}
|
||||
>
|
||||
{routes.length === 0
|
||||
? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-center',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
No routes are advertised on this machine.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: undefined}
|
||||
{routes.map(route => (
|
||||
<div
|
||||
key={route.node.id}
|
||||
className={cn(
|
||||
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-between',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
{route.prefix}
|
||||
</p>
|
||||
<Switch
|
||||
defaultSelected={route.enabled}
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData()
|
||||
form.set('id', machine.id)
|
||||
form.set('_method', 'routes')
|
||||
form.set('route', route.id)
|
||||
|
||||
form.set('enabled', String(checked))
|
||||
fetcher.submit(form, {
|
||||
method: 'POST',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
isDisabled={fetcher.state === 'submitting'}
|
||||
onPress={close}
|
||||
>
|
||||
Close
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react'
|
||||
import { Link } from '@remix-run/react'
|
||||
|
||||
import Menu from '~/components/Menu'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { type Machine, type Route } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import MenuOptions from './menu'
|
||||
|
||||
interface Props {
|
||||
readonly machine: Machine
|
||||
readonly routes: Route[]
|
||||
readonly magic?: string
|
||||
}
|
||||
|
||||
export default function MachineRow({ machine, routes, magic }: Props) {
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
? false
|
||||
: 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-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-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}
|
||||
<CopyIcon 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}
|
||||
<CopyIcon 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">
|
||||
<MenuOptions
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
magic={magic}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { KebabHorizontalIcon } from '@primer/octicons-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import MenuComponent from '~/components/Menu'
|
||||
import { Machine, 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'
|
||||
|
||||
interface MenuProps {
|
||||
machine: Machine
|
||||
routes: Route[]
|
||||
magic?: string
|
||||
}
|
||||
|
||||
export default function Menu({ machine, routes, magic }: MenuProps) {
|
||||
const renameState = useState(false)
|
||||
const expireState = useState(false)
|
||||
const removeState = useState(false)
|
||||
const routesState = useState(false)
|
||||
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Rename
|
||||
machine={machine}
|
||||
state={renameState}
|
||||
magic={magic}
|
||||
/>
|
||||
<Delete
|
||||
machine={machine}
|
||||
state={removeState}
|
||||
/>
|
||||
{expired
|
||||
? undefined
|
||||
: (
|
||||
<Expire
|
||||
machine={machine}
|
||||
state={expireState}
|
||||
/>
|
||||
)}
|
||||
<Routes
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
state={routesState}
|
||||
/>
|
||||
|
||||
<MenuComponent>
|
||||
<MenuComponent.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',
|
||||
)}
|
||||
>
|
||||
<KebabHorizontalIcon className="w-5" />
|
||||
</MenuComponent.Button>
|
||||
<MenuComponent.Items>
|
||||
<MenuComponent.ItemButton control={renameState}>
|
||||
Edit machine name
|
||||
</MenuComponent.ItemButton>
|
||||
<MenuComponent.ItemButton control={routesState}>
|
||||
Edit route settings
|
||||
</MenuComponent.ItemButton>
|
||||
<MenuComponent.Item className="opacity-50 hover:bg-transparent">
|
||||
Edit ACL tags
|
||||
</MenuComponent.Item>
|
||||
{expired
|
||||
? undefined
|
||||
: (
|
||||
<MenuComponent.ItemButton control={expireState}>
|
||||
Expire
|
||||
</MenuComponent.ItemButton>
|
||||
)}
|
||||
<MenuComponent.ItemButton
|
||||
className="text-red-500 dark:text-red-400"
|
||||
control={removeState}
|
||||
>
|
||||
Remove
|
||||
</MenuComponent.ItemButton>
|
||||
</MenuComponent.Items>
|
||||
</MenuComponent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { InfoIcon } from '@primer/octicons-react'
|
||||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { 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 { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
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, routes] = await Promise.all([
|
||||
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||
])
|
||||
|
||||
const context = await loadContext()
|
||||
let magic: string | undefined
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
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': {
|
||||
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Machine expired' })
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
if (!data.has('name')) {
|
||||
return json({ message: 'No name provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const name = String(data.get('name'))
|
||||
|
||||
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Machine renamed' })
|
||||
}
|
||||
|
||||
case 'routes': {
|
||||
if (!data.has('route') || !data.has('enabled')) {
|
||||
return json({ message: 'No route or enabled provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const route = String(data.get('route'))
|
||||
const enabled = data.get('enabled') === 'true'
|
||||
const postfix = enabled ? 'enable' : 'disable'
|
||||
|
||||
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Route updated' })
|
||||
}
|
||||
|
||||
default: {
|
||||
return json({ message: 'Invalid method' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
useLiveData({ interval: 3000 })
|
||||
const data = useLoaderData<typeof loader>()
|
||||
|
||||
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>
|
||||
<InfoIcon className="w-4 h-4" />
|
||||
</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}
|
||||
machine={machine}
|
||||
routes={data.routes.filter(route => route.node.id === machine.id)}
|
||||
magic={data.magic}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IssueDraftIcon } from '@primer/octicons-react'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center my-8'>
|
||||
<IssueDraftIcon className='w-24 h-24 text-gray-300 dark: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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
|
||||
import { Outlet, useLoaderData, useNavigation } from '@remix-run/react'
|
||||
import { ProgressBar } from 'react-aria-components'
|
||||
|
||||
import { ErrorPopup } from '~/components/Error'
|
||||
import Header from '~/components/Header'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
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) {
|
||||
// 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 loadContext()
|
||||
return {
|
||||
acl: context.acl,
|
||||
config: context.config,
|
||||
user: session.get('user'),
|
||||
}
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const nav = useNavigation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProgressBar
|
||||
aria-label="Loading..."
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 left-0 z-50 w-1/2 h-1',
|
||||
'bg-blue-500 dark:bg-blue-400 opacity-0',
|
||||
nav.state === 'loading' && 'animate-loading opacity-100',
|
||||
)}
|
||||
/>
|
||||
</ProgressBar>
|
||||
<Header data={data} />
|
||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<ErrorPopup type="embedded" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
|
||||
interface Props {
|
||||
magic?: string
|
||||
}
|
||||
|
||||
export default function Add({ magic }: Props) {
|
||||
const [username, setUsername] = useState('')
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>
|
||||
Add a new user
|
||||
</Dialog.Button>
|
||||
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Add a new user
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className="mb-8">
|
||||
Enter a username to create a new user.
|
||||
{' '}
|
||||
{magic
|
||||
? (
|
||||
<>
|
||||
Since Magic DNS is enabled, machines will be
|
||||
accessible via
|
||||
{' '}
|
||||
<Code>
|
||||
[machine].
|
||||
{username.length > 0 ? username : '[username]'}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
.
|
||||
</>
|
||||
)
|
||||
: undefined}
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
submit(event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="create" />
|
||||
<TextField
|
||||
label="Username"
|
||||
placeholder="my-new-user"
|
||||
name="username"
|
||||
state={[username, setUsername]}
|
||||
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={close}
|
||||
>
|
||||
Create
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react'
|
||||
|
||||
import Card from '~/components/Card'
|
||||
import Link from '~/components/Link'
|
||||
|
||||
import Add from './add'
|
||||
|
||||
interface Props {
|
||||
readonly magic: string | undefined
|
||||
}
|
||||
|
||||
export default function Auth({ magic }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
|
||||
<HomeIcon className="w-5 h-5 mb-2" />
|
||||
<h2 className="font-medium mb-1">
|
||||
Basic Authentication
|
||||
</h2>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
Users are not managed externally.
|
||||
Using OpenID Connect can create a better
|
||||
experience when using Headscale.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://headscale.net/oidc"
|
||||
name="Headscale OIDC Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
|
||||
<PasskeyFillIcon className="w-5 h-5 mb-2" />
|
||||
<h2 className="font-medium mb-1">
|
||||
User Management
|
||||
</h2>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
You can add, remove, and rename users here.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Add magic={magic} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react'
|
||||
|
||||
import Card from '~/components/Card'
|
||||
import Link from '~/components/Link'
|
||||
import { HeadplaneContext } from '~/utils/config/headplane'
|
||||
|
||||
import Add from './add'
|
||||
|
||||
interface Props {
|
||||
readonly oidc: NonNullable<HeadplaneContext['oidc']>
|
||||
readonly magic: string | undefined
|
||||
}
|
||||
|
||||
export default function Oidc({ oidc, magic }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
|
||||
<OrganizationIcon className="w-5 h-5 mb-2" />
|
||||
<h2 className="font-medium mb-1">
|
||||
OpenID Connect
|
||||
</h2>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
Users are managed through your
|
||||
{' '}
|
||||
<Link to={oidc.issuer} name="OIDC Provider">
|
||||
OpenID Connect provider
|
||||
</Link>
|
||||
{'. '}
|
||||
Groups and user information do not automatically sync.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://headscale.net/oidc"
|
||||
name="Headscale OIDC Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
|
||||
<PasskeyFillIcon className="w-5 h-5 mb-2" />
|
||||
<h2 className="font-medium mb-1">
|
||||
User Management
|
||||
</h2>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
You can still add users manually, however it is recommended
|
||||
that you manage users through your OIDC provider.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Add magic={magic} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { XIcon } from '@primer/octicons-react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
|
||||
interface Props {
|
||||
username: string
|
||||
magic?: string
|
||||
}
|
||||
|
||||
export default function Remove({ username, magic }: Props) {
|
||||
const submit = useSubmit()
|
||||
const dialogState = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="light"
|
||||
control={dialogState}
|
||||
className="rounded-full p-0 w-8 h-8"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Dialog control={dialogState}>
|
||||
<Dialog.Panel control={dialogState}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Delete
|
||||
{' '}
|
||||
{username}
|
||||
?
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className="mb-8">
|
||||
Are you sure you want to delete
|
||||
{' '}
|
||||
{username}
|
||||
?
|
||||
{' '}
|
||||
A deleted user cannot be recovered.
|
||||
{magic
|
||||
? (
|
||||
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
|
||||
{' '}
|
||||
Since Magic DNS is enabled, machines
|
||||
currently accessible via
|
||||
{' '}
|
||||
<Code>
|
||||
[machine].
|
||||
{username}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
{' '}
|
||||
will become orphaned and inaccessible.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
submit(event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="delete" />
|
||||
<input type="hidden" name="username" value={username} />
|
||||
<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}
|
||||
>
|
||||
Delete
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { PencilIcon } from '@primer/octicons-react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
|
||||
interface Props {
|
||||
username: string
|
||||
magic?: string
|
||||
}
|
||||
|
||||
export default function Rename({ username, magic }: Props) {
|
||||
const submit = useSubmit()
|
||||
const dialogState = useState(false)
|
||||
const [newName, setNewName] = useState(username)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="light"
|
||||
control={dialogState}
|
||||
className="rounded-full p-0 w-8 h-8"
|
||||
>
|
||||
<PencilIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Dialog control={dialogState}>
|
||||
<Dialog.Panel control={dialogState}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Rename
|
||||
{' '}
|
||||
{username}
|
||||
?
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className="mb-8">
|
||||
Enter a new username for
|
||||
{' '}
|
||||
{username}
|
||||
?
|
||||
{magic
|
||||
? (
|
||||
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
|
||||
{' '}
|
||||
Since Magic DNS is enabled, machines
|
||||
currently accessible via
|
||||
{' '}
|
||||
<Code>
|
||||
[machine].
|
||||
{username}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
{' '}
|
||||
will now become accessible via
|
||||
{' '}
|
||||
<Code>
|
||||
[machine].
|
||||
{newName.length > 0 ? newName : '[new-username]'}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
submit(event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="rename" />
|
||||
<input type="hidden" name="old" value={username} />
|
||||
<TextField
|
||||
label="Username"
|
||||
placeholder="my-new-name"
|
||||
name="new"
|
||||
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={close}
|
||||
>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { type DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
|
||||
import { PersonIcon } from '@primer/octicons-react'
|
||||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useActionData, useLoaderData, useSubmit } from '@remix-run/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
|
||||
import Attribute from '~/components/Attribute'
|
||||
import Card from '~/components/Card'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { type Machine, type User } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
import { del, post, pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import Auth from './auth'
|
||||
import Oidc from './oidc'
|
||||
import Remove from './remove'
|
||||
import Rename from './rename'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
|
||||
const [machines, apiUsers] = await Promise.all([
|
||||
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
])
|
||||
|
||||
const users = apiUsers.users.map(user => ({
|
||||
...user,
|
||||
machines: machines.nodes.filter(machine => machine.user.id === user.id),
|
||||
}))
|
||||
|
||||
const context = await loadContext()
|
||||
let magic: string | undefined
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
if (config.dns_config.magic_dns) {
|
||||
magic = config.dns_config.base_domain
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oidc: context.oidc,
|
||||
magic,
|
||||
users,
|
||||
}
|
||||
}
|
||||
|
||||
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')) {
|
||||
return json({ message: 'No method provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const method = String(data.get('_method'))
|
||||
|
||||
switch (method) {
|
||||
case 'create': {
|
||||
if (!data.has('username')) {
|
||||
return json({ message: 'No name provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const username = String(data.get('username'))
|
||||
await post('v1/user', session.get('hsApiKey')!, {
|
||||
name: username,
|
||||
})
|
||||
|
||||
return json({ message: `User ${username} created` })
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
if (!data.has('username')) {
|
||||
return json({ message: 'No name provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const username = String(data.get('username'))
|
||||
await del(`v1/user/${username}`, session.get('hsApiKey')!)
|
||||
return json({ message: `User ${username} deleted` })
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
if (!data.has('old') || !data.has('new')) {
|
||||
return json({ message: 'No old or new name provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const old = String(data.get('old'))
|
||||
const newName = String(data.get('new'))
|
||||
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!)
|
||||
return json({ message: `User ${old} renamed to ${newName}` })
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
if (!data.has('id') || !data.has('to') || !data.has('name')) {
|
||||
return json({ message: 'No ID or destination provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const id = String(data.get('id'))
|
||||
const to = String(data.get('to'))
|
||||
const name = String(data.get('name'))
|
||||
|
||||
try {
|
||||
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
|
||||
return json({ message: `Moved ${name} to ${to}` })
|
||||
} catch {
|
||||
return json({ message: `Failed to move ${name} to ${to}` }, {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return json({ message: 'Invalid method' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const [users, setUsers] = useState(data.users)
|
||||
const actionData = useActionData<typeof action>()
|
||||
useLiveData({ interval: 3000 })
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionData) {
|
||||
return
|
||||
}
|
||||
|
||||
toast(actionData.message)
|
||||
if (actionData.message.startsWith('Failed')) {
|
||||
setUsers(data.users)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actionData])
|
||||
|
||||
useEffect(() => {
|
||||
setUsers(data.users)
|
||||
}, [data.users])
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-medium mb-1.5">
|
||||
Users
|
||||
</h1>
|
||||
<p className="mb-8 text-md">
|
||||
Manage the users in your network and their permissions.
|
||||
Tip: You can drag machines between users to change ownership.
|
||||
</p>
|
||||
{data.oidc
|
||||
? (
|
||||
<Oidc
|
||||
oidc={data.oidc}
|
||||
magic={data.magic}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Auth magic={data.magic} />
|
||||
)}
|
||||
<ClientOnly fallback={
|
||||
<Users users={users} />
|
||||
}
|
||||
>
|
||||
{() => (
|
||||
<InteractiveUsers
|
||||
users={users}
|
||||
setUsers={setUsers}
|
||||
magic={data.magic}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type UserMachine = User & { machines: Machine[] }
|
||||
|
||||
interface UserProps {
|
||||
users: UserMachine[]
|
||||
setUsers?: (users: UserMachine[]) => void
|
||||
magic?: string
|
||||
}
|
||||
|
||||
function Users({ users, magic }: UserProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||
{users.map(user => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
magic={magic}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InteractiveUsers({ users, setUsers, magic }: UserProps) {
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<DndContext onDragEnd={(event) => {
|
||||
const { over, active } = event
|
||||
if (!over) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update the UI optimistically
|
||||
const newUsers = new Array<UserMachine>()
|
||||
const reference = active.data as DataRef<Machine>
|
||||
if (!reference.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore if the user is unchanged
|
||||
if (reference.current.user.name === over.id) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
newUsers.push({
|
||||
...user,
|
||||
machines: over.id === user.name
|
||||
? [...user.machines, reference.current]
|
||||
: user.machines.filter(m => m.id !== active.id),
|
||||
})
|
||||
}
|
||||
|
||||
setUsers?.(newUsers)
|
||||
const data = new FormData()
|
||||
data.append('_method', 'move')
|
||||
data.append('id', active.id.toString())
|
||||
data.append('to', over.id.toString())
|
||||
data.append('name', reference.current.givenName)
|
||||
|
||||
submit(data, {
|
||||
method: 'POST',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||
{users.map(user => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
magic={magic}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
function MachineChip({ machine }: { readonly machine: Machine }) {
|
||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||
id: machine.id,
|
||||
data: machine,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex items-center w-full gap-2 py-1',
|
||||
'hover:bg-ui-100 dark:hover:bg-ui-800 rounded-lg',
|
||||
)}
|
||||
style={{
|
||||
transform: transform
|
||||
? `translate3d(${transform.x.toString()}px, ${transform.y.toString()}px, 0)`
|
||||
: undefined,
|
||||
}}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<StatusCircle isOnline={machine.online} className="w-4 h-4 px-1 w-fit" />
|
||||
<Attribute
|
||||
name={machine.givenName}
|
||||
value={machine.ipAddresses[0]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CardProps {
|
||||
user: UserMachine
|
||||
magic?: string
|
||||
}
|
||||
|
||||
function UserCard({ user, magic }: CardProps) {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: user.name,
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef}>
|
||||
<Card
|
||||
variant="flat"
|
||||
className={cn(
|
||||
'max-w-full w-full overflow-visible h-full',
|
||||
isOver ? 'bg-ui-100 dark:bg-ui-800' : '',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<PersonIcon className="w-6 h-6" />
|
||||
<span className="text-lg font-mono">
|
||||
{user.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Rename username={user.name} magic={magic} />
|
||||
{user.machines.length === 0
|
||||
? (
|
||||
<Remove username={user.name} magic={magic} />
|
||||
)
|
||||
: undefined}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{user.machines.map(machine => (
|
||||
<MachineChip key={machine.id} machine={machine} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from '@remix-run/node'
|
||||
|
||||
export function loader() {
|
||||
return redirect('/machines')
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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 TextField from '~/components/TextField'
|
||||
import { type Key } from '~/types'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
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 loadContext()
|
||||
|
||||
// Only set if OIDC is properly enabled anyways
|
||||
if (context.oidc?.disableKeyLogin) {
|
||||
return startOidc(context.oidc, request)
|
||||
}
|
||||
|
||||
return {
|
||||
oidc: context.oidc?.issuer,
|
||||
apiKey: !context.oidc?.disableKeyLogin,
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const formData = await request.formData()
|
||||
const oidcStart = formData.get('oidc-start')
|
||||
|
||||
if (oidcStart) {
|
||||
const context = await loadContext()
|
||||
|
||||
if (!context.oidc) {
|
||||
throw new Error('An invalid OIDC configuration was provided')
|
||||
}
|
||||
|
||||
// We know it exists here because this action only happens on OIDC
|
||||
return startOidc(context.oidc, 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.toString()} 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="max-w-sm m-4 sm:m-0 rounded-2xl">
|
||||
<Card.Title>
|
||||
Welcome to Headplane
|
||||
</Card.Title>
|
||||
{data.apiKey
|
||||
? (
|
||||
<Form method="post">
|
||||
<Card.Text className="mb-8 text-sm">
|
||||
Enter an API key to authenticate with Headplane. You can generate
|
||||
one by running
|
||||
{' '}
|
||||
<Code>
|
||||
headscale apikeys create
|
||||
</Code>
|
||||
{' '}
|
||||
in your terminal.
|
||||
</Card.Text>
|
||||
|
||||
{actionData?.error
|
||||
? (
|
||||
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
|
||||
)
|
||||
: undefined}
|
||||
<TextField
|
||||
isRequired
|
||||
label="API Key"
|
||||
name="api-key"
|
||||
placeholder="API Key"
|
||||
/>
|
||||
<Button
|
||||
className="w-full mt-2.5"
|
||||
variant="heavy"
|
||||
type="submit"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Form>
|
||||
)
|
||||
: undefined}
|
||||
{showOr
|
||||
? (
|
||||
<div className="flex items-center gap-x-1.5 py-1">
|
||||
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
|
||||
<span className="text-gray-500 text-sm">or</span>
|
||||
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
|
||||
</div>
|
||||
)
|
||||
: undefined}
|
||||
{data.oidc
|
||||
? (
|
||||
<Form method="POST">
|
||||
<input type="hidden" name="oidc-start" value="true" />
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="heavy"
|
||||
type="submit"
|
||||
>
|
||||
Login with SSO
|
||||
</Button>
|
||||
</Form>
|
||||
)
|
||||
: undefined}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type LoaderFunctionArgs } from '@remix-run/node'
|
||||
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { finishOidc } from '~/utils/oidc'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const context = await loadContext()
|
||||
if (!context.oidc) {
|
||||
throw new Error('An invalid OIDC configuration was provided')
|
||||
}
|
||||
|
||||
return finishOidc(context.oidc, request)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@supports (scrollbar-gutter: stable) {
|
||||
html {
|
||||
scrollbar-gutter: stable
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type Key = {
|
||||
id: string;
|
||||
prefix: string;
|
||||
expiration: string;
|
||||
createdAt: Date;
|
||||
lastSeen: Date;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { User } from './User'
|
||||
|
||||
export interface Machine {
|
||||
id: string
|
||||
machineKey: string
|
||||
nodeKey: string
|
||||
discoKey: string
|
||||
ipAddresses: string[]
|
||||
name: string
|
||||
|
||||
user: User
|
||||
lastSeen: string
|
||||
expiry: string
|
||||
|
||||
preAuthKey?: unknown // TODO
|
||||
|
||||
createdAt: string
|
||||
registerMethod: 'REGISTER_METHOD_UNSPECIFIED'
|
||||
| 'REGISTER_METHOD_AUTH_KEY'
|
||||
| 'REGISTER_METHOD_CLI'
|
||||
| 'REGISTER_METHOD_OIDC'
|
||||
|
||||
forcedTags: string[]
|
||||
invalidTags: string[]
|
||||
validTags: string[]
|
||||
givenName: string
|
||||
online: boolean
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Machine } from './Machine'
|
||||
|
||||
export interface Route {
|
||||
id: string
|
||||
node: Machine
|
||||
prefix: string
|
||||
advertised: boolean
|
||||
enabled: boolean
|
||||
isPrimary: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt: string
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './Key'
|
||||
export * from './Machine'
|
||||
export * from './Route'
|
||||
export * from './User'
|
||||
@@ -0,0 +1,4 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
|
||||
@@ -0,0 +1,238 @@
|
||||
// Handle the configuration loading for headplane.
|
||||
// Functionally only used for all sorts of sanity checks across headplane.
|
||||
//
|
||||
// Around the codebase, this is referred to as the context
|
||||
|
||||
import { access, constants, readFile, writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { parse } from 'yaml'
|
||||
|
||||
import { checkIntegration, Integration } from '~/integration'
|
||||
|
||||
import { HeadscaleConfig, loadConfig } from './headscale'
|
||||
|
||||
export interface HeadplaneContext {
|
||||
headscaleUrl: string
|
||||
cookieSecret: string
|
||||
integration: Integration | undefined
|
||||
|
||||
config: {
|
||||
read: boolean
|
||||
write: boolean
|
||||
}
|
||||
|
||||
acl: {
|
||||
read: boolean
|
||||
write: boolean
|
||||
}
|
||||
|
||||
oidc?: {
|
||||
issuer: string
|
||||
client: string
|
||||
secret: string
|
||||
rootKey: string
|
||||
disableKeyLogin: boolean
|
||||
}
|
||||
}
|
||||
|
||||
let context: HeadplaneContext | undefined
|
||||
|
||||
export async function loadContext(): Promise<HeadplaneContext> {
|
||||
if (context) {
|
||||
return context
|
||||
}
|
||||
|
||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||
const { config, contextData } = await checkConfig(path)
|
||||
|
||||
let headscaleUrl = process.env.HEADSCALE_URL
|
||||
if (!headscaleUrl && !config) {
|
||||
throw new Error('HEADSCALE_URL not set')
|
||||
}
|
||||
|
||||
if (config) {
|
||||
headscaleUrl = headscaleUrl ?? config.server_url
|
||||
}
|
||||
|
||||
if (!headscaleUrl) {
|
||||
throw new Error('Missing server_url in headscale config')
|
||||
}
|
||||
|
||||
const cookieSecret = process.env.COOKIE_SECRET
|
||||
if (!cookieSecret) {
|
||||
throw new Error('COOKIE_SECRET not set')
|
||||
}
|
||||
|
||||
context = {
|
||||
headscaleUrl,
|
||||
cookieSecret,
|
||||
integration: await checkIntegration(),
|
||||
config: contextData,
|
||||
acl: await checkAcl(config),
|
||||
oidc: await checkOidc(config),
|
||||
}
|
||||
|
||||
console.log('Completed loading the Headplane Context')
|
||||
console.log('Headscale URL:', headscaleUrl)
|
||||
console.log('Integration:', context.integration?.name ?? 'None')
|
||||
console.log('Config:', contextData.read ? `Found ${contextData.write ? '' : '(Read Only)'}` : 'Unavailable')
|
||||
console.log('ACL:', context.acl.read ? `Found ${context.acl.write ? '' : '(Read Only)'}` : 'Unavailable')
|
||||
console.log('OIDC:', context.oidc ? 'Configured' : 'Unavailable')
|
||||
|
||||
return context
|
||||
}
|
||||
|
||||
export async function loadAcl() {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
const config = await loadConfig()
|
||||
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' }
|
||||
}
|
||||
}
|
||||
|
||||
export async function patchAcl(data: string) {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path) {
|
||||
try {
|
||||
const config = await loadConfig()
|
||||
path = config.acl_policy_path
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
throw new Error('No ACL file defined')
|
||||
}
|
||||
|
||||
await writeFile(path, data, 'utf8')
|
||||
}
|
||||
|
||||
async function checkConfig(path: string) {
|
||||
let config: HeadscaleConfig | undefined
|
||||
try {
|
||||
config = await loadConfig(path)
|
||||
} catch {
|
||||
return {
|
||||
config: undefined,
|
||||
contextData: {
|
||||
read: false,
|
||||
write: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let write = false
|
||||
try {
|
||||
await access(path, constants.W_OK)
|
||||
write = true
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
config,
|
||||
contextData: {
|
||||
read: true,
|
||||
write,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function checkAcl(config?: HeadscaleConfig) {
|
||||
let path = process.env.ACL_FILE
|
||||
if (!path && config) {
|
||||
path = config.acl_policy_path
|
||||
}
|
||||
|
||||
let read = false
|
||||
let write = false
|
||||
if (path) {
|
||||
try {
|
||||
await access(path, constants.R_OK)
|
||||
read = true
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
await access(path, constants.W_OK)
|
||||
write = true
|
||||
} catch {}
|
||||
}
|
||||
|
||||
return {
|
||||
read,
|
||||
write,
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOidc(config?: HeadscaleConfig) {
|
||||
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true'
|
||||
const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY
|
||||
if (!rootKey) {
|
||||
throw new Error('ROOT_API_KEY or API_KEY not set')
|
||||
}
|
||||
|
||||
let issuer = process.env.OIDC_ISSUER
|
||||
let client = process.env.OIDC_CLIENT_ID
|
||||
let secret = process.env.OIDC_CLIENT_SECRET
|
||||
|
||||
if (
|
||||
(issuer ?? client ?? secret)
|
||||
&& !(issuer && client && secret)
|
||||
&& !config
|
||||
) {
|
||||
throw new Error('OIDC environment variables are incomplete')
|
||||
}
|
||||
|
||||
if ((!issuer || !client || !secret) && config) {
|
||||
issuer = config.oidc?.issuer
|
||||
client = config.oidc?.client_id
|
||||
secret = config.oidc?.client_secret
|
||||
|
||||
if (!secret && config.oidc?.client_secret_path) {
|
||||
try {
|
||||
const data = await readFile(
|
||||
config.oidc.client_secret_path,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
if (data && data.length > 0) {
|
||||
secret = data.trim()
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(issuer ?? client ?? secret)
|
||||
&& !(issuer && client && secret)
|
||||
) {
|
||||
throw new Error('OIDC configuration is incomplete')
|
||||
}
|
||||
|
||||
if (!issuer || !client || !secret) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
issuer,
|
||||
client,
|
||||
secret,
|
||||
rootKey,
|
||||
disableKeyLogin,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
// Handle the configuration loading for headscale.
|
||||
// Functionally only used for reading and writing the configuration file.
|
||||
// Availability checks and other configuration checks are done in the headplane
|
||||
// configuration file that's adjacent to this one.
|
||||
//
|
||||
// Around the codebase, this is referred to as the config
|
||||
// Refer to this file on juanfont/headscale for the default values:
|
||||
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/config.go
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
import { type Document, parseDocument } from 'yaml'
|
||||
import { z } from 'zod'
|
||||
|
||||
const goBool = z
|
||||
.union([z.boolean(), z.literal('true'), z.literal('false')])
|
||||
.transform((value) => {
|
||||
if (typeof value === 'boolean') {
|
||||
return value
|
||||
}
|
||||
|
||||
return value === 'true'
|
||||
})
|
||||
|
||||
const goDuration = z.union([z.literal(0), z.string()])
|
||||
|
||||
const HeadscaleConfig = z.object({
|
||||
tls_letsencrypt_cache_dir: z.string().default('/var/www/cache'),
|
||||
tls_letsencrypt_challenge_type: z.enum(['HTTP-01', 'TLS-ALPN-01']).default('HTTP-01'),
|
||||
|
||||
tls_letsencrypt_hostname: z.string().optional(),
|
||||
tls_letsencrypt_listen: z.string().optional(),
|
||||
|
||||
tls_cert_path: z.string().optional(),
|
||||
tls_key_path: z.string().optional(),
|
||||
|
||||
server_url: z.string().regex(/^https?:\/\//),
|
||||
listen_addr: z.string(),
|
||||
metrics_listen_addr: z.string().optional(),
|
||||
grpc_listen_addr: z.string().default(':50443'),
|
||||
grpc_allow_insecure: goBool.default(false),
|
||||
|
||||
disable_check_updates: goBool.default(false),
|
||||
ephemeral_node_inactivity_timeout: goDuration.default('120s'),
|
||||
randomize_client_port: goBool.default(false),
|
||||
acl_policy_path: z.string().optional(),
|
||||
|
||||
acme_email: z.string().optional(),
|
||||
acme_url: z.string().optional(),
|
||||
|
||||
unix_socket: z.string().default('/var/run/headscale/headscale.sock'),
|
||||
unix_socket_permission: z.string().default('0o770'),
|
||||
|
||||
tuning: z.object({
|
||||
batch_change_delay: goDuration.default('800ms'),
|
||||
node_mapsession_buffered_chan_size: z.number().default(30),
|
||||
}).optional(),
|
||||
|
||||
noise: z.object({
|
||||
private_key_path: z.string(),
|
||||
}),
|
||||
|
||||
log: z.object({
|
||||
level: z.string().default('info'),
|
||||
format: z.enum(['text', 'json']).default('text'),
|
||||
}).default({ level: 'info', format: 'text' }),
|
||||
|
||||
logtail: z.object({
|
||||
enabled: goBool.default(false),
|
||||
}).default({ enabled: false }),
|
||||
|
||||
cli: z.object({
|
||||
address: z.string().optional(),
|
||||
api_key: z.string().optional(),
|
||||
timeout: goDuration.default('10s'),
|
||||
insecure: goBool.default(false),
|
||||
}).optional(),
|
||||
|
||||
prefixes: z.object({
|
||||
allocation: z.enum(['sequential', 'random']).default('sequential'),
|
||||
v4: z.string(),
|
||||
v6: z.string(),
|
||||
}),
|
||||
|
||||
dns_config: z.object({
|
||||
override_local_dns: goBool.default(false),
|
||||
nameservers: z.array(z.string()).default([]),
|
||||
restricted_nameservers: z.record(z.array(z.string())).default({}),
|
||||
domains: z.array(z.string()).default([]),
|
||||
extra_records: z.array(z.object({
|
||||
name: z.string(),
|
||||
type: z.literal('A'),
|
||||
value: z.string(),
|
||||
})).default([]),
|
||||
magic_dns: goBool.default(false),
|
||||
base_domain: z.string().default('headscale.net'),
|
||||
}),
|
||||
|
||||
oidc: z.object({
|
||||
only_start_if_oidc_is_available: goBool.default(false),
|
||||
issuer: z.string().optional(),
|
||||
client_id: z.string().optional(),
|
||||
client_secret: z.string().optional(),
|
||||
client_secret_path: z.string().optional(),
|
||||
scope: z.array(z.string()).default(['openid', 'profile', 'email']),
|
||||
extra_params: z.record(z.unknown()).default({}),
|
||||
allowed_domains: z.array(z.string()).optional(),
|
||||
allowed_users: z.array(z.string()).optional(),
|
||||
allowed_groups: z.array(z.string()).optional(),
|
||||
strip_email_domain: goBool.default(false),
|
||||
expiry: goDuration.default('180d'),
|
||||
use_expiry_from_token: goBool.default(false),
|
||||
}).optional(),
|
||||
|
||||
database: z.union([
|
||||
z.object({
|
||||
type: z.literal('sqlite'),
|
||||
debug: goBool.default(false),
|
||||
sqlite: z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('sqlite3'),
|
||||
debug: goBool.default(false),
|
||||
sqlite: z.object({
|
||||
path: z.string(),
|
||||
}),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal('postgres'),
|
||||
debug: goBool.default(false),
|
||||
postgres: z.object({
|
||||
host: z.string(),
|
||||
port: z.number(),
|
||||
name: z.string(),
|
||||
user: z.string(),
|
||||
pass: z.string(),
|
||||
ssl: goBool.default(true),
|
||||
max_open_conns: z.number().default(10),
|
||||
max_idle_conns: z.number().default(10),
|
||||
conn_max_idle_time_secs: z.number().default(3600),
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
|
||||
derp: z.object({
|
||||
server: z.object({
|
||||
enabled: goBool.default(true),
|
||||
region_id: z.number().optional(),
|
||||
region_code: z.string().optional(),
|
||||
region_name: z.string().optional(),
|
||||
stun_listen_addr: z.string().optional(),
|
||||
private_key_path: z.string().optional(),
|
||||
|
||||
ipv4: z.string().optional(),
|
||||
ipv6: z.string().optional(),
|
||||
automatically_add_embedded_derp_region: goBool.default(true),
|
||||
}),
|
||||
|
||||
urls: z.array(z.string()).optional(),
|
||||
paths: z.array(z.string()).optional(),
|
||||
auto_update_enabled: goBool.default(true),
|
||||
update_frequency: goDuration.default('24h'),
|
||||
}),
|
||||
})
|
||||
|
||||
export type HeadscaleConfig = z.infer<typeof HeadscaleConfig>
|
||||
|
||||
export let configYaml: Document | undefined
|
||||
export let config: HeadscaleConfig | undefined
|
||||
|
||||
export async function loadConfig(path?: string) {
|
||||
if (config) {
|
||||
return config
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
throw new Error('Path is required to lazy load config')
|
||||
}
|
||||
|
||||
const data = await readFile(path, 'utf8')
|
||||
configYaml = parseDocument(data)
|
||||
|
||||
if (process.env.HEADSCALE_CONFIG_UNSTRICT === 'true') {
|
||||
config = configYaml.toJSON() as HeadscaleConfig
|
||||
console.log('Loaded Headscale configuration in non-strict mode')
|
||||
console.log('By using this mode you forfeit GitHub issue support')
|
||||
console.log('This is very dangerous and comes with a few caveats:')
|
||||
console.log('- Headplane could very easily crash')
|
||||
console.log('- Headplane could break your Headscale installation')
|
||||
console.log('- The UI could throw random errors/show incorrect data')
|
||||
console.log('')
|
||||
return config
|
||||
}
|
||||
|
||||
try {
|
||||
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
console.log('Failed to parse the Headscale configuration file!')
|
||||
console.log('The following schema issues were found:')
|
||||
for (const issue of error.issues) {
|
||||
const path = issue.path.map(String).join('.')
|
||||
const message = issue.message
|
||||
|
||||
console.log(`- '${path}': ${message}`)
|
||||
}
|
||||
|
||||
console.log('')
|
||||
console.log('Please fix the configuration file and try again.')
|
||||
console.log('Headplane will operate as if no config is present.')
|
||||
console.log('')
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// This is so obscenely dangerous, please have a check around it
|
||||
export async function patchConfig(partial: Record<string, unknown>) {
|
||||
if (!configYaml || !config) {
|
||||
throw new Error('Config not loaded')
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(partial)) {
|
||||
// If the key is something like `test.bar."foo.bar"`, then we treat
|
||||
// the foo.bar as a single key, and not as two keys, so that needs
|
||||
// to be split correctly.
|
||||
|
||||
// Iterate through each character, and if we find a dot, we check if
|
||||
// the next character is a quote, and if it is, we skip until the next
|
||||
// quote, and then we skip the next character, which should be a dot.
|
||||
// If it's not a quote, we split it.
|
||||
const path = []
|
||||
let temp = ''
|
||||
let inQuote = false
|
||||
|
||||
for (const element of key) {
|
||||
if (element === '"') {
|
||||
inQuote = !inQuote
|
||||
}
|
||||
|
||||
if (element === '.' && !inQuote) {
|
||||
path.push(temp.replaceAll('"', ''))
|
||||
temp = ''
|
||||
continue
|
||||
}
|
||||
|
||||
temp += element
|
||||
}
|
||||
|
||||
// Push the remaining element
|
||||
path.push(temp.replaceAll('"', ''))
|
||||
configYaml.setIn(path, value)
|
||||
}
|
||||
|
||||
config = process.env.HEADSCALE_CONFIG_UNSTRICT === 'true'
|
||||
? configYaml.toJSON() as HeadscaleConfig
|
||||
: (await HeadscaleConfig.parseAsync(configYaml.toJSON()))
|
||||
|
||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||
await writeFile(path, configYaml.toString(), 'utf8')
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { loadContext } from './config/headplane'
|
||||
|
||||
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 loadContext()
|
||||
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 loadContext()
|
||||
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 loadContext()
|
||||
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>)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
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'
|
||||
|
||||
import { HeadplaneContext } from './config/headplane'
|
||||
|
||||
type OidcConfig = NonNullable<HeadplaneContext['oidc']>
|
||||
|
||||
export async function startOidc(oidc: OidcConfig, req: Request) {
|
||||
const session = await getSession(req.headers.get('Cookie'))
|
||||
if (session.has('hsApiKey')) {
|
||||
return redirect('/', {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const issuerUrl = new URL(oidc.issuer)
|
||||
const oidcClient = {
|
||||
client_id: oidc.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', req.url)
|
||||
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
|
||||
callback.hostname = req.headers.get('Host') ?? ''
|
||||
const authUrl = new URL(processed.authorization_endpoint)
|
||||
|
||||
authUrl.searchParams.set('client_id', oidcClient.client_id)
|
||||
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: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function finishOidc(oidc: OidcConfig, req: Request) {
|
||||
const session = await getSession(req.headers.get('Cookie'))
|
||||
if (session.has('hsApiKey')) {
|
||||
return redirect('/', {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const issuerUrl = new URL(oidc.issuer)
|
||||
const oidcClient = {
|
||||
client_id: oidc.client,
|
||||
client_secret: oidc.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(req.url),
|
||||
state,
|
||||
)
|
||||
|
||||
if (isOAuth2Error(parameters)) {
|
||||
throw new Error('Invalid response from the OIDC provider')
|
||||
}
|
||||
|
||||
const callback = new URL('/admin/oidc/callback', req.url)
|
||||
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
|
||||
callback.hostname = req.headers.get('Host') ?? ''
|
||||
|
||||
const tokenResponse = await authorizationCodeGrantRequest(
|
||||
processed,
|
||||
oidcClient,
|
||||
parameters,
|
||||
callback.href,
|
||||
verifier,
|
||||
)
|
||||
|
||||
const 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()
|
||||
|
||||
const keyResponse = await post<{ apiKey: string }>(
|
||||
'v1/apikey',
|
||||
oidc.rootKey,
|
||||
{
|
||||
expiration: expDate,
|
||||
},
|
||||
)
|
||||
|
||||
session.set('hsApiKey', keyResponse.apiKey)
|
||||
session.set('user', {
|
||||
name: claims.name ? String(claims.name) : 'Anonymous',
|
||||
email: claims.email ? String(claims.email) : undefined,
|
||||
})
|
||||
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { createCookieSessionStorage } from '@remix-run/node' // Or cloudflare/deno
|
||||
|
||||
export 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
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useRevalidator } from '@remix-run/react'
|
||||
import { useEffect } from 'react'
|
||||
import { useInterval } from 'usehooks-ts'
|
||||
|
||||
interface Props {
|
||||
interval: number
|
||||
}
|
||||
|
||||
export function useLiveData({ interval }: Props) {
|
||||
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])
|
||||
return revalidator
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 126 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 142 KiB |
@@ -0,0 +1,24 @@
|
||||
# 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'
|
||||
@@ -0,0 +1,145 @@
|
||||
# Advanced Integration
|
||||
|
||||

|
||||
|
||||
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.
|
||||
Additionally, with an integration provider, you can automatically reload the configuration or ACLs when they are changed.
|
||||
|
||||
## 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)
|
||||
|
||||

|
||||
|
||||
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.
|
||||
|
||||
## Automatic Configuration Reload
|
||||
|
||||
When the configuration file is changed, Headscale will need to be restarted to apply the changes.
|
||||
Similarly, when the ACL file is changed, Headscale will need to be sent a `SIGHUP` signal to reload the ACLs.
|
||||
Currently there are 2 integration providers that can do this for you:
|
||||
|
||||
### Docker Integration
|
||||
|
||||
To enable the Docker integration, set `HEADSCALE_INTEGRATION=docker` in the environment variables.
|
||||
Additionally, you'll need to pass in the `HEADSCALE_CONTAINER` environment variable.
|
||||
This should be either the name or ID of the Headscale container (you can retrieve this using `docker ps`).
|
||||
If the other integrations aren't setup, then Headplane will automatically disable the Docker integration.
|
||||
|
||||
By default the integration will check for `/var/run/docker.sock`, however you can override this by
|
||||
setting the `DOCKER_SOCK` environment variable if you use a different configuration than the default.
|
||||
When setting `DOCKER_SOCK`, you'll need to include the protocol (e.g., `unix://` or `tcp://`).
|
||||
Headplane currently does not support the HTTPS protocol for the Docker socket.
|
||||
|
||||
#### Example Docker 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_INTEGRATION: 'docker'
|
||||
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
|
||||
ROOT_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.
|
||||
|
||||
### Native Linux Integration (Beta)
|
||||
|
||||
The native integration for Linux relies on the `/proc` directory to locate the Headscale process.
|
||||
To enable it, set the `HEADSCALE_INTEGRATION=proc` value in the environment variables.
|
||||
Because of the way this integration works, it only supports automatically reloading ACLs.
|
||||
It's still very experimental and may not work in all environments.
|
||||
|
||||
## Configuration Scenarios
|
||||
|
||||
Since the configuration is fairly modular you can have a variety of different setups.
|
||||
This mostly applies to the Docker integration since the native integration isn't fully featured yet.
|
||||
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.
|
||||
@@ -0,0 +1,53 @@
|
||||
# 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!
|
||||
ROOT_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`).
|
||||
@@ -0,0 +1,56 @@
|
||||
# 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_CONFIG_UNSTRICT`**: This will disable the strict configuration loader (default: `false`).
|
||||
|
||||
#### Docker Integration
|
||||
The Docker integration allows Headplane to manage the Headscale docker container.
|
||||
You'll need to provide these variables if you want to use this feature.
|
||||
Keep in mind that `DOCKER_SOCK` must start with a protocol (e.g., `unix://`).
|
||||
Secure API is currently not supported.
|
||||
|
||||
- **`DOCKER_SOCK`**: The protocol and path to the Docker socket (default: `unix:///var/run/docker.sock`).
|
||||
- **`HEADSCALE_CONTAINER`**: The name of the Headscale container (required for Docker integration).
|
||||
|
||||
### SSO/OpenID Connect
|
||||
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.
|
||||
- **`ROOT_API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long).
|
||||
- **`DISABLE_API_KEY_LOGIN`**: If you want to disable API key login, set this to `true`.
|
||||
|
||||
Here's what an example with Authelia would look like if you used the same client for both Headscale and Headplane.
|
||||
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'
|
||||
```
|
||||
@@ -0,0 +1,5 @@
|
||||
import tale from 'eslint-config-tale'
|
||||
|
||||
export default [
|
||||
...tale
|
||||
]
|
||||
Generated
-4268
File diff suppressed because it is too large
Load Diff
+63
-27
@@ -1,36 +1,72 @@
|
||||
{
|
||||
"name": "headplane",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"sideEffects": false,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"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"
|
||||
"build": "remix vite:build",
|
||||
"dev": "remix vite:dev",
|
||||
"lint": "eslint --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",
|
||||
"@primer/octicons-react": "^19.9.0",
|
||||
"@react-aria/toast": "3.0.0-beta.11",
|
||||
"@react-stately/toast": "3.0.0-beta.3",
|
||||
"@remix-run/node": "^2.9.2",
|
||||
"@remix-run/react": "^2.9.2",
|
||||
"@remix-run/serve": "^2.9.2",
|
||||
"@uiw/codemirror-theme-github": "^4.22.0",
|
||||
"@uiw/react-codemirror": "^4.22.0",
|
||||
"clsx": "^2.1.1",
|
||||
"isbot": "^5.1.6",
|
||||
"oauth4webapi": "^2.10.4",
|
||||
"react": "19.0.0-beta-26f2496093-20240514",
|
||||
"react-aria-components": "^1.2.0",
|
||||
"react-codemirror-merge": "^4.22.0",
|
||||
"react-dom": "19.0.0-beta-26f2496093-20240514",
|
||||
"remix-utils": "^7.6.0",
|
||||
"tailwind-merge": "^2.3.0",
|
||||
"tailwindcss-react-aria-components": "^1.1.2",
|
||||
"undici": "^6.16.1",
|
||||
"usehooks-ts": "^3.1.0",
|
||||
"yaml": "^2.4.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.0.0",
|
||||
"vite": "^5.0.3"
|
||||
"@remix-run/dev": "^2.9.2",
|
||||
"@types/react": "npm:types-react@beta",
|
||||
"@types/react-dom": "npm:types-react-dom@beta",
|
||||
"autoprefixer": "^10.4.19",
|
||||
"babel-plugin-react-compiler": "0.0.0-experimental-c23de8d-20240515",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-config-tale": "^2.0.4",
|
||||
"postcss": "^8.4.38",
|
||||
"tailwindcss": "^3.4.3",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-babel": "^1.2.0",
|
||||
"vite-tsconfig-paths": "^4.2.1"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@tanstack/svelte-query": "^5.28.4",
|
||||
"@zerodevx/svelte-toast": "^0.9.5",
|
||||
"clsx": "^2.1.0",
|
||||
"oauth4webapi": "^2.10.3"
|
||||
"overrides": {
|
||||
"@types/react": "npm:types-react@beta",
|
||||
"@types/react-dom": "npm:types-react-dom@beta"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20",
|
||||
"pnpm": ">=9 <10"
|
||||
},
|
||||
"pnpm": {
|
||||
"patchedDependencies": {
|
||||
"@react-aria/overlays@3.22.0": "patches/@react-aria__overlays@3.22.0.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
diff --git a/dist/usePreventScroll.mjs b/dist/usePreventScroll.mjs
|
||||
index 532a72f9e9ba985d036b7eba0f950ff6552898d9..7c9263667c2463c8e84fd07064076132fe53284c 100644
|
||||
--- a/dist/usePreventScroll.mjs
|
||||
+++ b/dist/usePreventScroll.mjs
|
||||
@@ -48,7 +48,7 @@ function $49c51c25361d4cd2$export$ee0f7cc6afcd1c18(options = {}) {
|
||||
// For most browsers, all we need to do is set `overflow: hidden` on the root element, and
|
||||
// add some padding to prevent the page from shifting when the scrollbar is hidden.
|
||||
function $49c51c25361d4cd2$var$preventScrollStandard() {
|
||||
- return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, "paddingRight", `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"));
|
||||
+ return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"));
|
||||
}
|
||||
// Mobile Safari is a whole different beast. Even with overflow: hidden,
|
||||
// it still scrolls the page in many situations:
|
||||
@@ -161,7 +161,7 @@ function $49c51c25361d4cd2$var$preventScrollMobileSafari() {
|
||||
// enable us to scroll the window to the top, which is required for the rest of this to work.
|
||||
let scrollX = window.pageXOffset;
|
||||
let scrollY = window.pageYOffset;
|
||||
- restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, "scroll", onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, "paddingRight", `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"), $49c51c25361d4cd2$var$setStyle(document.body, "marginTop", `-${scrollY}px`), ()=>{
|
||||
+ restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, "scroll", onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"), $49c51c25361d4cd2$var$setStyle(document.body, "marginTop", `-${scrollY}px`), ()=>{
|
||||
window.scrollTo(scrollX, scrollY);
|
||||
});
|
||||
// Scroll to the top. The negative margin on the body will make this appear the same.
|
||||
Generated
+9905
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -1,6 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
-22
@@ -1,22 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
Vendored
-14
@@ -1,14 +0,0 @@
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
interface Locals {
|
||||
apiKey: string;
|
||||
}
|
||||
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export { };
|
||||
@@ -1,15 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,12 +0,0 @@
|
||||
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;
|
||||
};
|
||||
@@ -1,10 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
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'
|
||||
@@ -1,38 +0,0 @@
|
||||
<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>
|
||||
@@ -1,22 +0,0 @@
|
||||
<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>
|
||||
@@ -1,87 +0,0 @@
|
||||
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 +0,0 @@
|
||||
// place files you want to import through the `$lib` alias in this folder.
|
||||
@@ -1,29 +0,0 @@
|
||||
import type { User } from "./User";
|
||||
|
||||
export type Machine = {
|
||||
id: string;
|
||||
machineKey: string;
|
||||
nodeKey: string;
|
||||
discoKey: string;
|
||||
ipAddresses: string[];
|
||||
name: string;
|
||||
|
||||
user: User;
|
||||
lastSeen: Date;
|
||||
expiry: Date;
|
||||
|
||||
|
||||
preAuthKey?: unknown; // TODO
|
||||
|
||||
createdAt: Date;
|
||||
registerMethod: 'REGISTER_METHOD_UNSPECIFIED'
|
||||
| 'REGISTER_METHOD_AUTH_KEY'
|
||||
| 'REGISTER_METHOD_CLI'
|
||||
| 'REGISTER_METHOD_OIDC'
|
||||
|
||||
forcedTags: string[];
|
||||
invalidTags: string[];
|
||||
validTags: string[];
|
||||
givenName: string;
|
||||
online: boolean
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { Machine } from "./Machine";
|
||||
|
||||
export type Route = {
|
||||
id: string;
|
||||
node: Machine;
|
||||
prefix: string;
|
||||
advertised: boolean;
|
||||
enabled: boolean;
|
||||
isPrimary: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
deletedAt: Date;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export type User = {
|
||||
id: string;
|
||||
name: string;
|
||||
createdAt: Date;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user