Compare commits

..

135 Commits

Author SHA1 Message Date
Aarnav Tale f0f8a6f01b chore: v0.1.8 2024-06-02 01:40:05 -04:00
Aarnav Tale 3bd5cc99aa fix: handle 0001-01-01 00:00:00 as the no-expiry timestamp 2024-06-02 01:37:31 -04:00
Aarnav Tale 937fa6f6fa fix: use proper route ids for key mapping 2024-06-02 01:34:58 -04:00
Aarnav Tale 3ffbabd7fc feat: add functional machine overview page and fix types 2024-06-02 01:33:40 -04:00
Aarnav Tale c08203cc76 fix: prevent user deletion if they have machines owned 2024-06-02 00:54:20 -04:00
Aarnav Tale 57f045eaf6 fix: support top level await on the server 2024-05-30 11:41:36 -04:00
Aarnav Tale faa583bb38 chore: v0.1.7 2024-05-30 10:41:55 -04:00
Aarnav Tale 98ea2cb06f feat: add a way to escape hatch from strict config checking 2024-05-30 10:41:40 -04:00
Aarnav Tale 868d85bbeb chore: log about context loading 2024-05-30 10:28:37 -04:00
Aarnav Tale dbd9d39da9 style: cleanup vite.config.ts 2024-05-30 10:20:53 -04:00
Aarnav Tale 72cc9d8974 fix: flexible duration and go boolean handling 2024-05-30 10:20:41 -04:00
Aarnav Tale ed50c48965 fix: handle configs better and propagate errors 2024-05-30 10:15:02 -04:00
Aarnav Tale e19dbda5ed fix: headscale expiry can be a literal 0 2024-05-26 19:41:56 -04:00
Aarnav Tale a72cc1bb1c chore: document the new integration providers 2024-05-26 19:40:46 -04:00
Aarnav Tale f0e4868252 feat: configure support for generic integrations
Co-authored-by: Gage Orsburn <gageorsburn@live.com>
2024-05-26 19:23:24 -04:00
Aarnav Tale 1a30185047 chore: v0.1.6 2024-05-22 14:15:05 -04:00
Aarnav Tale c7e59b137c docs: solidify DOCKER_SOCK and ROOT_API_KEY usage 2024-05-22 12:43:42 -04:00
Aarnav Tale 694b22f205 style: cleanup oidc spaghetti 2024-05-21 23:57:03 -04:00
Aarnav Tale 84855d9d51 chore: update pnpm and enforce engines 2024-05-21 23:50:43 -04:00
Aarnav Tale 6bea3b2a55 fix: user grid on mobile should be full width 2024-05-21 17:23:33 -04:00
Aarnav Tale 2ee1adf142 chore: delete entry files because they aren't modified 2024-05-21 17:22:11 -04:00
Aarnav Tale 06d7d1ccad feat: add support for split dns 2024-05-21 17:21:14 -04:00
Aarnav Tale 2aae6bfc06 chore: v0.1.5 2024-05-20 14:08:10 -04:00
Aarnav Tale 6794b1b2dd fix: don't log the context 2024-05-20 14:06:38 -04:00
Aarnav Tale a6582ef835 fix: button responders on acl page never worked 2024-05-20 14:05:27 -04:00
Aarnav Tale 3e51e4861d feat: use strictly typed configs and context 2024-05-20 14:05:09 -04:00
Aarnav Tale 0a12cdb3d6 fix: call button onPress even without state handler 2024-05-20 14:02:04 -04:00
Aarnav Tale 78698dcbba fix: DISABLE_API_KEY_LOGIN should only work when set to true 2024-05-19 19:15:29 -04:00
Aarnav Tale 260fb870e1 fix: when renaming users start with old names 2024-05-15 23:46:15 -04:00
Aarnav Tale 2b1e7be193 chore: v0.1.4 (attempt 2) 2024-05-15 22:27:03 -04:00
Aarnav Tale 65a76a7fa2 fix: docker needs the patches dir 2024-05-15 22:26:36 -04:00
Aarnav Tale 3d554a2434 chore: v0.1.4 2024-05-15 22:23:19 -04:00
Aarnav Tale d57ee2677c feat: react 19 to take advantage of compiler 2024-05-15 22:22:38 -04:00
Aarnav Tale 2f02ccc362 style: eslint changes 2024-05-15 21:54:40 -04:00
Aarnav Tale 37a31e30c7 chore: update eslint package 2024-05-15 21:54:34 -04:00
Aarnav Tale e7c6271322 chore: cleanup login page 2024-05-15 21:54:02 -04:00
Aarnav Tale 3deec75715 fix: Promise.all on concurrent promises 2024-05-15 21:53:39 -04:00
Aarnav Tale 18999357a8 fix: return the revalidator in useLiveData 2024-05-15 21:53:12 -04:00
Aarnav Tale e6eba645c4 feat: implement complete user control 2024-05-15 21:52:19 -04:00
Aarnav Tale f563335fab feat: add loading indicator 2024-05-11 23:07:24 -04:00
Aarnav Tale 6102fabfcb fix: add fallback values from the config 2024-05-11 23:07:06 -04:00
Aarnav Tale 87430ccf7b feat: continue moving older components to aria 2024-05-05 23:38:41 -04:00
Aarnav Tale ee42016a67 fix: patch @react-aria/overlays to support scroll gutter 2024-05-04 16:21:35 -04:00
Aarnav Tale 9f5ac6a9ef chore: v0.1.3 2024-05-04 15:07:27 -04:00
Aarnav Tale 7f55ad826c chore: add link to headscale acl faq on acl page 2024-05-04 15:04:55 -04:00
Aarnav Tale 2cc65e8783 chore: do a feature check for scrollbar-gutter-stable 2024-05-04 15:04:40 -04:00
Aarnav Tale 5ff09e44d9 feat: cleanup header and extract to component 2024-05-04 15:04:22 -04:00
Aarnav Tale c6cdcf35eb chore: switch from heroicons to primer icons 2024-05-04 15:03:21 -04:00
Aarnav Tale 61ae161496 chore: update readme 2024-05-01 02:51:03 -04:00
Aarnav Tale 82f5e17207 feat: add various machine operations 2024-05-01 02:47:45 -04:00
Aarnav Tale a63f4e4d52 fix: API_KEY is no longer required for login to work 2024-05-01 02:47:36 -04:00
Aarnav Tale a57e777a6b feat: completely nuke headlessui and consolidate to aria 2024-05-01 02:21:54 -04:00
Aarnav Tale 8205f2b99b feat: replace the old modal completely 2024-04-30 11:03:53 -04:00
Aarnav Tale c95218b8dd fix: prune an image for readme 2024-04-29 20:33:51 -04:00
Aarnav Tale 769556efda fix: make the toaster click-throughable 2024-04-29 20:32:14 -04:00
Aarnav Tale ea47bb5fec chore: cleanup and use aria components in places 2024-04-29 20:32:00 -04:00
Aarnav Tale 3abd617a90 feat: switch to react aria and replace many components 2024-04-29 20:31:29 -04:00
Aarnav Tale cbecf85979 chore: update readme images 2024-04-23 01:15:31 -04:00
Aarnav Tale 0ebadc26bc fix: oidc will fail in the dev config 2024-04-22 14:34:03 -04:00
Aarnav Tale 252e78d618 feat: facelift overall machines page and refactor 2024-04-22 14:33:51 -04:00
Aarnav Tale c6930732ee fix: oidc config variables were named incorrectly 2024-04-22 13:21:48 -04:00
Aarnav Tale f04b17109b fix: only sighup if we have docker 2024-04-17 17:44:12 -04:00
Aarnav Tale 0ff9e6fdc3 feat: implement better ssr fallbacks 2024-04-17 17:42:44 -04:00
Aarnav Tale 94174ebcce feat: add dumb yaml detection 2024-04-17 17:20:35 -04:00
Aarnav Tale c2fe69ec17 chore: add acl file instructions 2024-04-15 04:06:18 -04:00
Aarnav Tale b285753b24 fix: omg why do i forget these things here 2024-04-15 04:02:23 -04:00
Aarnav Tale 8eac733a5d fix: we shouldn't return early if the env var isn't there 2024-04-15 03:52:51 -04:00
Aarnav Tale 89a7cb5aae chore: stop prefixing tags with v 2024-04-15 03:41:28 -04:00
Aarnav Tale 39868b5043 feat: implement raw acl editing on the web ui 2024-04-15 03:40:52 -04:00
Aarnav Tale ca32590e54 fix: string constructor in js causes oidc fast track 2024-04-07 14:01:38 -04:00
Aarnav Tale a846249be1 chore: add a warning to the compose.yaml 2024-04-05 18:32:12 -04:00
Aarnav Tale bcf00beb75 fix: add publish archs 2024-04-02 15:08:47 -04:00
Aarnav Tale 6dae5d647a chore: various fixes 2024-04-01 19:41:39 -04:00
Aarnav Tale d787b8517e fix: protect the restart endpoint with auth 2024-04-01 19:33:22 -04:00
Aarnav Tale bdb00b6cd7 feat: make the modal and dropdown more workable 2024-03-31 19:06:06 -04:00
Aarnav Tale f1347803a4 fix: logout is a button 2024-03-31 18:14:28 -04:00
Aarnav Tale 6fa27e5d28 fix: read oidc configuration from env then config 2024-03-30 19:11:13 -04:00
Aarnav Tale 78140927ad fix: use either HEADSCALE_URL or config.server_url 2024-03-30 19:05:31 -04:00
Aarnav Tale a47fb61549 feat: implement node removal 2024-03-30 18:58:16 -04:00
Aarnav Tale 1d9f4553eb feat: support children in confirmation modal 2024-03-30 18:42:49 -04:00
Aarnav Tale b146e4c3a8 feat: add confirmation modal 2024-03-30 18:39:46 -04:00
Aarnav Tale 381c3d6df4 feat: create shared dropdown component 2024-03-30 15:22:34 -04:00
Aarnav Tale 37f84cfba5 fix: use the request url protocol for oidc 2024-03-30 05:26:22 -04:00
Aarnav Tale 52c8e2c3bc fix: remix relies on build dir 2024-03-30 05:06:17 -04:00
Aarnav Tale 0a8d3e26f4 chore: we now have images! 2024-03-30 04:51:37 -04:00
Aarnav Tale 9342e81733 chore: wording on actions should be ghcr.io 2024-03-30 04:51:34 -04:00
Aarnav Tale 224621d9a5 fix: actions needs ghcr.io 2024-03-30 04:47:46 -04:00
Aarnav Tale 5c0b823c12 fix: replace chokidar with fs.watch 2024-03-30 04:20:16 -04:00
Aarnav Tale 935e015be9 feat: add loading states 2024-03-30 04:15:00 -04:00
Aarnav Tale cc1427882f feat: watch config file for external edits 2024-03-30 02:53:51 -04:00
Aarnav Tale 02e5e3e92a fix: set cookie expiry to match expiry of api key 2024-03-30 02:47:42 -04:00
Aarnav Tale 196c4763e5 style: facelift the login page 2024-03-30 02:46:21 -04:00
Aarnav Tale 50bcfa9c04 feat: add logout and dropdown 2024-03-30 02:44:06 -04:00
Aarnav Tale 3f65c7e610 fix: acl path needs to be resolved 2024-03-30 01:56:06 -04:00
Aarnav Tale 4e129d527a feat: implement context sanity checks 2024-03-30 01:51:40 -04:00
Aarnav Tale e216d4171f feat: support readonly config and no docker sock 2024-03-29 21:46:21 -04:00
Aarnav Tale 029f395673 fix: use absolute markdown paths 2024-03-29 19:43:57 -04:00
Aarnav Tale 9244d63826 feat: redo documentation 2024-03-29 19:41:56 -04:00
Aarnav Tale c2060ed45a chore: update readme 2024-03-29 16:19:31 -04:00
Aarnav Tale 22b6af685a feat: add proper error component 2024-03-29 16:14:35 -04:00
Aarnav Tale 4a1cdaa432 feat: create shared button component 2024-03-29 16:02:14 -04:00
Aarnav Tale b5658750a9 feat: begin support for components and dark mode 2024-03-29 15:41:10 -04:00
Aarnav Tale abb957c573 feat: restart docker container 2024-03-29 01:53:32 -04:00
Aarnav Tale 8148e242dc fix: handle a case when headscale is unreachable 2024-03-29 00:35:15 -04:00
Aarnav Tale 27f310fb35 feat: support managing magic dns 2024-03-28 19:49:05 -04:00
Aarnav Tale a340b3d263 fix: oops forgot to disable buttons with empty input 2024-03-28 19:45:00 -04:00
Aarnav Tale 5df9be1b8e fix: dragging works correctly 2024-03-28 19:40:43 -04:00
Aarnav Tale 358629a93b feat: experimental support for draggable domains 2024-03-28 18:47:28 -04:00
Aarnav Tale 1019c1ebf0 chore: update readme progress 2024-03-28 17:27:48 -04:00
Aarnav Tale e6e3110753 feat: make dns configuration editable 2024-03-28 17:27:40 -04:00
Aarnav Tale f0c52d8a8b feat: make the config patchable 2024-03-28 17:03:37 -04:00
Aarnav Tale c6435ae2b4 chore: add local headscale for testing 2024-03-28 14:58:00 -04:00
Aarnav Tale db9d08b7f3 feat: add modal to disable magic dns 2024-03-28 14:20:19 -04:00
Aarnav Tale 6d4e6ebce1 style: add bottom margin on container 2024-03-28 14:20:04 -04:00
Aarnav Tale b129a9b006 chore: add pending page for acls 2024-03-28 14:03:25 -04:00
Aarnav Tale 4baf3d7ce7 feat: start dns page 2024-03-26 17:42:45 -04:00
Aarnav Tale 77702c1bae chore: add new tab links 2024-03-26 17:42:35 -04:00
Aarnav Tale cce30bd29c feat: show node acl tags on the main screen 2024-03-26 16:55:37 -04:00
Aarnav Tale 5bb2f7dfa3 feat: add configuration loading 2024-03-26 16:55:20 -04:00
Aarnav Tale fe285b4e6d feat: add documentation on deployment 2024-03-26 11:25:55 -04:00
Aarnav Tale 6ec517012e feat: add sign in via api key 2024-03-26 11:11:12 -04:00
Aarnav Tale 19538ad839 chore: add preview 2024-03-26 11:01:58 -04:00
Aarnav Tale c62da81ea7 feat: add user page 2024-03-26 10:57:11 -04:00
Aarnav Tale 22c4b9504b fix: nice one remix 2024-03-26 10:32:55 -04:00
Aarnav Tale 1a7fd1fad2 chore: add license and readme again 2024-03-26 10:24:28 -04:00
Aarnav Tale 1cf9901d23 feat: add docker build tag 2024-03-26 10:18:11 -04:00
Aarnav Tale bef9b6d46d feat: add docker build 2024-03-26 10:10:34 -04:00
Aarnav Tale 3b1f0ae6f8 feat: add machine info page 2024-03-26 09:34:57 -04:00
Aarnav Tale b8498a9db3 fix: the error boundary shouldn't have <html> 2024-03-26 09:08:34 -04:00
Aarnav Tale ed9cc23020 fix: handle apikey expiration errors gracefully 2024-03-26 09:04:27 -04:00
Aarnav Tale abaf71636e feat: add machine data 2024-03-25 18:54:04 -04:00
Aarnav Tale b38eb0885f chore: add env.example 2024-03-25 17:54:47 -04:00
Aarnav Tale 58992efa2e feat: oidc based authentication 2024-03-25 17:52:23 -04:00
Aarnav Tale 025cc4f6f1 feat: initial bootstrappy stuff 2024-03-25 17:50:58 -04:00
Aarnav Tale 70c42751e3 chore: initial commit from create-remix 2024-03-25 17:50:10 -04:00
118 changed files with 16184 additions and 5141 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.dockerignore
Dockerfile
/.cache
/build
.env
+6 -6
View File
@@ -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
+42
View File
@@ -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
View File
@@ -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-*
-1
View File
@@ -1 +0,0 @@
engine-strict=true
+50
View File
@@ -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
View File
@@ -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" ]
+22 -11
View File
@@ -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.
![Preview](/assets/main-preview.png)
- [ ] 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
+43
View File
@@ -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>
)
}
+37
View File
@@ -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}
/>
)
}
+53
View File
@@ -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 })
+12
View File
@@ -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>
)
}
+143
View File
@@ -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 })
+46
View File
@@ -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>
)
}
+121
View File
@@ -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>
)
}
+30
View File
@@ -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>
)
}
+98
View File
@@ -0,0 +1,98 @@
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
import {
Button as AriaButton,
Menu as AriaMenu,
MenuItem,
MenuTrigger,
Popover
} from 'react-aria-components'
import { cn } from '~/utils/cn'
function Button(properties: Parameters<typeof AriaButton>[0]) {
return (
<AriaButton
{...properties}
className={cn(
'outline-none',
properties.className
)}
aria-label='Menu'
/>
)
}
function Items(properties: Parameters<typeof AriaMenu>[0]) {
return (
<Popover className={cn(
'mt-2 rounded-md',
'bg-ui-50 dark:bg-ui-800',
'overflow-hidden z-50',
'border border-ui-200 dark:border-ui-600',
'entering:animate-in exiting:animate-out',
'entering:fade-in entering:zoom-in-95',
'exiting:fade-out exiting:zoom-out-95',
'fill-mode-forwards origin-left-right'
)}
>
<AriaMenu
{...properties}
className={cn(
'outline-none',
'divide-y divide-ui-200 dark:divide-ui-600',
properties.className
)}
>
{properties.children}
</AriaMenu>
</Popover>
)
}
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
}
function ItemButton(properties: ButtonProperties) {
return (
<MenuItem className='outline-none'>
<AriaButton
{...properties}
className={cn(
'px-4 py-2 w-full outline-none text-left',
'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className
)}
aria-label='Menu Dialog'
// If control is passed, set the state value
onPress={event => {
properties.onPress?.(event)
properties.control?.[1](true)
}}
/>
</MenuItem>
)
}
function Item(properties: Parameters<typeof MenuItem>[0]) {
return (
<MenuItem
{...properties}
className={cn(
'px-4 py-2 w-full outline-none',
'hover:bg-ui-200 dark:hover:bg-ui-700',
properties.className
)}
/>
)
}
function Menu({ children }: { readonly children: ReactNode }) {
return (
<MenuTrigger>
{children}
</MenuTrigger>
)
}
export default Object.assign(Menu, { Button, Item, ItemButton, Items })
+16
View File
@@ -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>
)
}
+23
View File
@@ -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>
)
}
+24
View File
@@ -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>
)
}
+34
View File
@@ -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>
)
}
+32
View File
@@ -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>
)
}
+37
View File
@@ -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 })
+38
View File
@@ -0,0 +1,38 @@
import { type Dispatch, type SetStateAction } from 'react'
import {
Input,
TextField as AriaTextField
} from 'react-aria-components'
import { cn } from '~/utils/cn'
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
readonly label: string;
readonly placeholder: string;
readonly state?: [string, Dispatch<SetStateAction<string>>];
}
export default function TextField(properties: TextFieldProperties) {
return (
<AriaTextField
{...properties}
aria-label={properties.label}
className='w-full'
>
<Input
placeholder={properties.placeholder}
value={properties.state?.[0]}
name={properties.name}
className={cn(
'block px-2.5 py-1.5 w-full rounded-lg my-1',
'border border-ui-200 dark:border-ui-600',
'dark:bg-ui-800 dark:text-ui-300',
properties.className
)}
onChange={event => {
properties.state?.[1](event.target.value)
}}
/>
</AriaTextField>
)
}
+79
View File
@@ -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>
)
}
+43
View File
@@ -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 })
+62
View File
@@ -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)
})
}
+127
View File
@@ -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
+58
View File
@@ -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}`)
}
}
}
+83
View File
@@ -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
+48
View File
@@ -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 />
}
+125
View File
@@ -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>
</>
)
}
+43
View File
@@ -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}
</>
)
}
+175
View File
@@ -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>
)
}
+228
View File
@@ -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>
)
}
+62
View File
@@ -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>
)
}
+139
View File
@@ -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>
)
}
+101
View File
@@ -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>
)
}
+116
View File
@@ -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>
)
}
+189
View File
@@ -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&apos;t install Tailscale on
by advertising IP ranges as subnet routes.
</Dialog.Text>
<div className={cn(
'rounded-lg overflow-y-auto my-2',
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
'border border-zinc-200 dark:border-zinc-700',
)}
>
{routes.length === 0
? (
<div
className={cn(
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-center',
'text-ui-600 dark:text-ui-300',
)}
>
<p>
No routes are advertised on this machine.
</p>
</div>
)
: undefined}
{routes.map(route => (
<div
key={route.node.id}
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between',
)}
>
<p>
{route.prefix}
</p>
<Switch
defaultSelected={route.enabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData()
form.set('id', machine.id)
form.set('_method', 'routes')
form.set('route', route.id)
form.set('enabled', String(checked))
fetcher.submit(form, {
method: 'POST',
})
}}
/>
</div>
))}
</div>
<div className="mt-6 flex justify-end gap-2 mt-6">
<Dialog.Action
variant="cancel"
isDisabled={fetcher.state === 'submitting'}
onPress={close}
>
Close
</Dialog.Action>
</div>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -0,0 +1,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>
)
}
+91
View File
@@ -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>
</>
)
}
+166
View File
@@ -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>
</>
)
}
+13
View File
@@ -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>
)
}
+76
View File
@@ -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" />
</>
)
}
+82
View File
@@ -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>
)
}
+49
View File
@@ -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>
)
}
+56
View File
@@ -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>
)
}
+92
View File
@@ -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>
</>
)
}
+107
View File
@@ -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>
</>
)
}
+351
View File
@@ -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>
)
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from '@remix-run/node'
export function loader() {
return redirect('/machines')
}
+163
View File
@@ -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>
)
}
+15
View File
@@ -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)
}
})
}
+13
View File
@@ -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)
}
+9
View File
@@ -0,0 +1,9 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@supports (scrollbar-gutter: stable) {
html {
scrollbar-gutter: stable
}
}
+7
View File
@@ -0,0 +1,7 @@
export type Key = {
id: string;
prefix: string;
expiration: string;
createdAt: Date;
lastSeen: Date;
}
+28
View File
@@ -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
}
+13
View File
@@ -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
}
+5
View File
@@ -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'
+4
View File
@@ -0,0 +1,4 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
+238
View File
@@ -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,
}
}
+266
View File
@@ -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')
}
+69
View File
@@ -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>)
}
+171
View File
@@ -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),
},
})
}
+36
View File
@@ -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
}
}
)
+35
View File
@@ -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

+24
View File
@@ -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'
+145
View File
@@ -0,0 +1,145 @@
# Advanced Integration
![Integration Preview](/assets/advanced-preview.png)
With the advanced integration it's possible to control Access Control Lists (ACLs) and the Headscale configuration via the Headplane UI.
Every single aspect of this integration is optional, meaning you can only use what you want.
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)
![ACL Preview](/assets/acl-preview.png)
Headplane will enable the `Access Controls` tab if it is able to read an ACL file from Headscale.<br>
The ACL file path is read from the following sources in order of priority:
- **Environment Variable**: If you set the `ACL_FILE` environment variable, Headplane will read the file from that path.
- **Configuration Integration**: If you've set this up, then Headplane will read the `acl_policy_path` key from the configuration file.
## 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.
+53
View File
@@ -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`).
+56
View File
@@ -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'
```
+5
View File
@@ -0,0 +1,5 @@
import tale from 'eslint-config-tale'
export default [
...tale
]
-4268
View File
File diff suppressed because it is too large Load Diff
+63 -27
View File
@@ -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.
+9905
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -1,6 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

-22
View File
@@ -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;
}
-14
View File
@@ -1,14 +0,0 @@
declare global {
namespace App {
// interface Error {}
interface Locals {
apiKey: string;
}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export { };
-15
View File
@@ -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>
-12
View File
@@ -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;
};
-10
View File
@@ -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;
}
-36
View File
@@ -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'
-38
View File
@@ -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>
-22
View File
@@ -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>
-87
View File
@@ -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
View File
@@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.
-29
View File
@@ -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
}
-13
View File
@@ -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;
}
-5
View File
@@ -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