mirror of
https://github.com/tale/headplane.git
synced 2026-07-29 00:58:55 +00:00
Compare commits
192 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2229f547a9 | |||
| 65cc278a59 | |||
| 1555846df2 | |||
| e8c1cadf54 | |||
| d867769025 | |||
| 1d6066d3f0 | |||
| 1d821251a9 | |||
| a0d6905123 | |||
| 4095ed2a68 | |||
| 58e98278d1 | |||
| 90f0bf2555 | |||
| ea2ffdf0c1 | |||
| 9aedd9baad | |||
| 690b52d8c6 | |||
| a72a3d6e5f | |||
| c4c1fd8aab | |||
| 9801ef453d | |||
| d041a62fcd | |||
| 0e6b5ea6d0 | |||
| a2054786f8 | |||
| 5a46fd0a97 | |||
| b8999161a2 | |||
| 224cbbdcaf | |||
| 75ba3a3dc7 | |||
| 4f57fdb43b | |||
| b170e11dd6 | |||
| 6b278309ed | |||
| ea8ecfb28f | |||
| 099bd3bcb8 | |||
| 0aa0406ea6 | |||
| 3cc726320a | |||
| bda151f4e8 | |||
| 6d411853d5 | |||
| dc4d05a2d9 | |||
| 361859f374 | |||
| c60652e39f | |||
| 1db385e716 | |||
| ab0cb7b782 | |||
| fd73832879 | |||
| 7804d83181 | |||
| fe40a5734e | |||
| c0bdf91f6b | |||
| bd46f97121 | |||
| d71e55d7af | |||
| ddd20fe027 | |||
| dca5f9d149 | |||
| be8ce7a2fe | |||
| 6fa0e265fe | |||
| 74e87bd80e | |||
| d01699009d | |||
| 08db192db1 | |||
| 3ea5fed8f6 | |||
| dd9d6cd550 | |||
| f82244f85a | |||
| 896aaad61e | |||
| e5dece5b7b | |||
| 2797525969 | |||
| f0f8a6f01b | |||
| 3bd5cc99aa | |||
| 937fa6f6fa | |||
| 3ffbabd7fc | |||
| c08203cc76 | |||
| 57f045eaf6 | |||
| faa583bb38 | |||
| 98ea2cb06f | |||
| 868d85bbeb | |||
| dbd9d39da9 | |||
| 72cc9d8974 | |||
| ed50c48965 | |||
| e19dbda5ed | |||
| a72cc1bb1c | |||
| f0e4868252 | |||
| 1a30185047 | |||
| c7e59b137c | |||
| 694b22f205 | |||
| 84855d9d51 | |||
| 6bea3b2a55 | |||
| 2ee1adf142 | |||
| 06d7d1ccad | |||
| 2aae6bfc06 | |||
| 6794b1b2dd | |||
| a6582ef835 | |||
| 3e51e4861d | |||
| 0a12cdb3d6 | |||
| 78698dcbba | |||
| 260fb870e1 | |||
| 2b1e7be193 | |||
| 65a76a7fa2 | |||
| 3d554a2434 | |||
| d57ee2677c | |||
| 2f02ccc362 | |||
| 37a31e30c7 | |||
| e7c6271322 | |||
| 3deec75715 | |||
| 18999357a8 | |||
| e6eba645c4 | |||
| f563335fab | |||
| 6102fabfcb | |||
| 87430ccf7b | |||
| ee42016a67 | |||
| 9f5ac6a9ef | |||
| 7f55ad826c | |||
| 2cc65e8783 | |||
| 5ff09e44d9 | |||
| c6cdcf35eb | |||
| 61ae161496 | |||
| 82f5e17207 | |||
| a63f4e4d52 | |||
| a57e777a6b | |||
| 8205f2b99b | |||
| c95218b8dd | |||
| 769556efda | |||
| ea47bb5fec | |||
| 3abd617a90 | |||
| cbecf85979 | |||
| 0ebadc26bc | |||
| 252e78d618 | |||
| c6930732ee | |||
| f04b17109b | |||
| 0ff9e6fdc3 | |||
| 94174ebcce | |||
| c2fe69ec17 | |||
| b285753b24 | |||
| 8eac733a5d | |||
| 89a7cb5aae | |||
| 39868b5043 | |||
| ca32590e54 | |||
| a846249be1 | |||
| bcf00beb75 | |||
| 6dae5d647a | |||
| d787b8517e | |||
| bdb00b6cd7 | |||
| f1347803a4 | |||
| 6fa27e5d28 | |||
| 78140927ad | |||
| a47fb61549 | |||
| 1d9f4553eb | |||
| b146e4c3a8 | |||
| 381c3d6df4 | |||
| 37f84cfba5 | |||
| 52c8e2c3bc | |||
| 0a8d3e26f4 | |||
| 9342e81733 | |||
| 224621d9a5 | |||
| 5c0b823c12 | |||
| 935e015be9 | |||
| cc1427882f | |||
| 02e5e3e92a | |||
| 196c4763e5 | |||
| 50bcfa9c04 | |||
| 3f65c7e610 | |||
| 4e129d527a | |||
| e216d4171f | |||
| 029f395673 | |||
| 9244d63826 | |||
| c2060ed45a | |||
| 22b6af685a | |||
| 4a1cdaa432 | |||
| b5658750a9 | |||
| abb957c573 | |||
| 8148e242dc | |||
| 27f310fb35 | |||
| a340b3d263 | |||
| 5df9be1b8e | |||
| 358629a93b | |||
| 1019c1ebf0 | |||
| e6e3110753 | |||
| f0c52d8a8b | |||
| c6435ae2b4 | |||
| db9d08b7f3 | |||
| 6d4e6ebce1 | |||
| b129a9b006 | |||
| 4baf3d7ce7 | |||
| 77702c1bae | |||
| cce30bd29c | |||
| 5bb2f7dfa3 | |||
| fe285b4e6d | |||
| 6ec517012e | |||
| 19538ad839 | |||
| c62da81ea7 | |||
| 22c4b9504b | |||
| 1a7fd1fad2 | |||
| 1cf9901d23 | |||
| bef9b6d46d | |||
| 3b1f0ae6f8 | |||
| b8498a9db3 | |||
| ed9cc23020 | |||
| abaf71636e | |||
| b38eb0885f | |||
| 58992efa2e | |||
| 025cc4f6f1 | |||
| 70c42751e3 |
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
|
||||
/.cache
|
||||
/build
|
||||
.env
|
||||
+6
-6
@@ -1,7 +1,7 @@
|
||||
PUBLIC_HEADSCALE_URL=https://tailscale.example.com
|
||||
API_KEY=abcdefghijklmnopqrstuvwxyz
|
||||
ROOT_API_KEY=abcdefghijklmnopqrstuvwxyz
|
||||
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
|
||||
OIDC_CLIENT_ID=headscale
|
||||
OIDC_CLIENT_ID=headscale
|
||||
OIDC_ISSUER=https://sso.example.com
|
||||
OIDC_CLIENT_SECRET=super_secret_client_secret
|
||||
DISABLE_API_KEY_LOGIN=true
|
||||
HEADSCALE_CONTAINER=headscale
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
CONFIG_FILE=/etc/headscale/config.yaml
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
name: Publish Nightly Docker Image
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and Publish Nightly
|
||||
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 }}
|
||||
tags: |
|
||||
type=edge,branch=main
|
||||
|
||||
- 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
|
||||
@@ -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
|
||||
@@ -0,0 +1,44 @@
|
||||
name: 'Build'
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 9
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
+2
-7
@@ -1,10 +1,5 @@
|
||||
.DS_Store
|
||||
node_modules
|
||||
|
||||
/.cache
|
||||
/build
|
||||
/.svelte-kit
|
||||
/package
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
### 0.3.1 (October 3, 2024)
|
||||
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
|
||||
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
|
||||
- Added the option for debug logs with the `DEBUG=true` environment variable.
|
||||
|
||||
### 0.3.0 (September 25, 2024)
|
||||
- Bumped the minimum supported version of Headscale to 0.23.
|
||||
- Updated the UI to respect `dns.use_username_in_magic_dns`.
|
||||
|
||||
### 0.2.4 (August 24, 2024)
|
||||
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
|
||||
- Removed the `ACL_FILE` environment variable since it's no longer needed.
|
||||
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
|
||||
- Fixed a bug where removing Split DNS configurations would crash the UI.
|
||||
|
||||
### 0.2.3 (August 23, 2024)
|
||||
- Change the minimum required version of Headscale to 0.23-beta2
|
||||
- Support the new API policy mode for Headscale 0.23-beta1
|
||||
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
|
||||
- If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24))
|
||||
|
||||
### 0.2.2 (August 2, 2024)
|
||||
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
|
||||
- Added a new logger utility that shows categories, levels, and timestamps.
|
||||
- Reimplemented the integration system to be more resilient and log more information.
|
||||
- Fixed an issue where the /proc integration found `undefined` PIDs.
|
||||
|
||||
### 0.2.1 (July 7, 2024)
|
||||
- Added the ability to manage custom DNS records on your Tailnet.
|
||||
- ACL tags for machines are now able to be changed via the machine menu.
|
||||
- Fixed a bug where the ACL editor did not show the diffs correctly.
|
||||
- Fixed an issue that stopped the "Discard changes" button in the ACL editor from working.
|
||||
|
||||
### 0.2.0 (June 23, 2024)
|
||||
- Fix the dropdown options for machines not working on the machines page.
|
||||
- Add an option to change the machine owner in the dropdown (aside from the users page).
|
||||
|
||||
### 0.1.9 (June 2, 2024)
|
||||
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
|
||||
- Utilize magic DNS hostnames for the machine overview page.
|
||||
- Fixed the expiry issue once and for all.
|
||||
- Add a nightly build with the `ghcr.io/tale/headplane:edge` tag
|
||||
|
||||
### 0.1.8 (June 2, 2024)
|
||||
- Built basic functionality for the machine overview page (by machine ID).
|
||||
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
|
||||
- Prevent users from being deleted if they still have ownership of machines.
|
||||
- Fixed some type issues where `Date` was being used instead of `string` for timestamps.
|
||||
|
||||
### 0.1.7 (May 30, 2024)
|
||||
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
|
||||
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
|
||||
- Made the strict configuration loader more lenient to allow for more flexibility.
|
||||
- Added `HEADSCALE_CONFIG_UNSTRICT`=true to revert back to a weaker configuration loader.
|
||||
- Headplane's context now only loads once at start instead of being lazy-loaded.
|
||||
- Improved logging and error propagation so that it's easier to debug issues.
|
||||
|
||||
### 0.1.6 (May 22, 2024)
|
||||
- Added experimental support for advanced integration without Docker.
|
||||
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
|
||||
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
|
||||
|
||||
### 0.1.5 (May 20, 2024)
|
||||
- Robust configuration handling with fallbacks based on the headscale source.
|
||||
- Support for `client_secret_path` on configuration file based OIDC.
|
||||
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
|
||||
- `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works).
|
||||
- Fixed button responders not actually being invoked (should fix the ACL page).
|
||||
|
||||
### 0.1.4 (May 15, 2024)
|
||||
|
||||
- Users can now be created, renamed, and deleted on the users page.
|
||||
- Machines can be dragged between users to change their ownership.
|
||||
- The login page actually respects the `DISABLE_API_KEY_LOGIN` variable.
|
||||
- Implemented some fixes that should stop dialogs from hanging a webpage.
|
||||
- Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues).
|
||||
- Upgrade other dependencies
|
||||
|
||||
### 0.1.3 (May 4, 2024)
|
||||
|
||||
- Switched to a better icon set for the UI.
|
||||
- Support stable scrollbar gutter if supported by the browser.
|
||||
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
|
||||
|
||||
### 0.1.2 (May 1, 2024)
|
||||
|
||||
- Added support for renaming, expiring, removing, and managing the routes of a machine.
|
||||
- Implemented an expiry check for machines which now reflect on the machine table.
|
||||
- Fixed an issue where `HEADSCALE_CONTAINER` was needed to start even without the Docker integration.
|
||||
- Removed the requirement for the root `API_KEY` unless OIDC was being used for authentication.
|
||||
- Switched to [React Aria](https://react-spectrum.adobe.com/react-aria/) for better accessibility support.
|
||||
- Cleaned up various different UI inconsistencies and copied components that could've been abstracted.
|
||||
- Added a changelog for any new versions going forward.
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
RUN npm install -g pnpm
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY patches ./patches
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
RUN pnpm prune --prod
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build /app/build
|
||||
COPY --from=build /app/node_modules /app/node_modules
|
||||
RUN echo '{"type":"module"}' > /app/package.json
|
||||
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
CMD [ "node_modules/.bin/remix-serve", "./build/server/index.js" ]
|
||||
@@ -1,21 +1,45 @@
|
||||
# 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.
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="./assets/preview-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="./assets/preview-light.png"
|
||||
>
|
||||
<img
|
||||
alt="Preview"
|
||||
src="./assets/preview-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
- [ ] Editable tags, machine names, users, etc
|
||||
- [ ] ACL control through Docker integration
|
||||
- [ ] OIDC based login for the web UI
|
||||
- [ ] Automated API key regeneration
|
||||
- [ ] Editable headscale configuration
|
||||
Headscale is a self-hosted version of the Tailscale control server, however, it currently lacks a first-party web UI.
|
||||
Headplane aims to solve this issue by providing a GUI that can deeply integrate with the Headscale server.
|
||||
It's able to replicate nearly all of the functions of the official Tailscale SaaS UI, including:
|
||||
|
||||
- Machine/Node expiry, network routing, name, and owner management
|
||||
- Access Control List (ACL) and tagging configuration
|
||||
- Support for OpenID Connect (OIDC) as a login provider
|
||||
- DNS and *safe* Headscale configuration management
|
||||
|
||||
## Deployment
|
||||
Instructions for deploying this will come soon. It will utilize Docker to support advanced features.
|
||||
> For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
|
||||
|
||||
For fully-featured deployments, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
|
||||
This includes automatic management of ACLs, DNS settings, and Headscale configuration.
|
||||
*This is the closest experience to the Tailscale UI that can be achieved with Headscale and Headplane.*
|
||||
*If you aren't sure which one to pick, we recommend this.*
|
||||
|
||||
If your environment is not able to support the advanced deployment, you can still use the basic deployment.
|
||||
For basic deployments, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
|
||||
It does not include automatic management of ACLs, DNS settings, or the Headscale configuration,
|
||||
instead requiring manual editing and reloading when making changes.
|
||||
|
||||
## Contributing
|
||||
If you would like to contribute, please install a relatively modern version of Node.js and NPM.
|
||||
Clone this repository, run `npm install`, and then run `npm run dev` to start the development server.
|
||||
If you would like to contribute, please install a relatively modern version of Node.js and PNPM.
|
||||
Clone this repository, run `pnpm install`, and then run `pnpm dev` to start the development server.
|
||||
|
||||
> Copyright (c) 2024 Aarnav Tale
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CopyIcon } from '@primer/octicons-react'
|
||||
|
||||
import { toast } from './Toaster'
|
||||
|
||||
interface Props {
|
||||
name: string
|
||||
value: string
|
||||
isCopyable?: boolean
|
||||
}
|
||||
|
||||
export default function Attribute({ name, value, isCopyable }: Props) {
|
||||
const canCopy = isCopyable ?? false
|
||||
return (
|
||||
<dl className="flex gap-1 text-sm w-full">
|
||||
<dt className="w-1/2 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1">
|
||||
{name}
|
||||
</dt>
|
||||
|
||||
{canCopy
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(value)
|
||||
toast(`Copied ${name}`)
|
||||
}}
|
||||
>
|
||||
<dd className="min-w-0 truncate px-2 py-1">
|
||||
{value}
|
||||
</dd>
|
||||
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
|
||||
</button>
|
||||
)
|
||||
: (
|
||||
<dd className="min-w-0 truncate px-2 py-1">
|
||||
{value}
|
||||
</dd>
|
||||
)}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
import { Button as AriaButton } from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
readonly variant?: 'heavy' | 'light'
|
||||
}
|
||||
|
||||
export default function Button(properties: ButtonProperties) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
properties.variant === 'heavy'
|
||||
? 'bg-main-700 dark:bg-main-800'
|
||||
: 'bg-main-200 dark:bg-main-700/30',
|
||||
properties.variant === 'heavy'
|
||||
? 'hover:bg-main-800 dark:hover:bg-main-700'
|
||||
: 'hover:bg-main-300 dark:hover:bg-main-600/30',
|
||||
properties.variant === 'heavy'
|
||||
? 'text-white'
|
||||
: 'text-ui-700 dark:text-ui-300',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.className,
|
||||
)}
|
||||
// If control is passed, set the state value
|
||||
onPress={properties.control
|
||||
? () => {
|
||||
properties.control?.[1](true)
|
||||
}
|
||||
: properties.onPress}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { type HTMLProps } from 'react'
|
||||
import { Heading as AriaHeading } from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
function Title(properties: Parameters<typeof AriaHeading>[0]) {
|
||||
return (
|
||||
<AriaHeading
|
||||
{...properties}
|
||||
slot='title'
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-6 mb-5',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
{...properties}
|
||||
className={cn(
|
||||
'text-base leading-6 my-0',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type Properties = HTMLProps<HTMLDivElement> & {
|
||||
variant?: 'raised' | 'flat';
|
||||
}
|
||||
|
||||
function Card(properties: Properties) {
|
||||
return (
|
||||
<div
|
||||
{...properties}
|
||||
className={cn(
|
||||
'w-full max-w-md overflow-hidden rounded-xl p-4',
|
||||
properties.variant === 'flat'
|
||||
? 'bg-transparent shadow-none'
|
||||
: 'bg-ui-50 dark:bg-ui-900 shadow-sm',
|
||||
'border border-ui-200 dark:border-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Card, { Title, Text })
|
||||
@@ -0,0 +1,12 @@
|
||||
import clsx from 'clsx'
|
||||
import { type HTMLProps } from 'react'
|
||||
|
||||
type Properties = HTMLProps<HTMLSpanElement>
|
||||
|
||||
export default function Code(properties: Properties) {
|
||||
return (
|
||||
<code className={clsx('bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md', properties.className)}>
|
||||
{properties.children}
|
||||
</code>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/* eslint-disable unicorn/no-keyword-prefix */
|
||||
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Dialog as AriaDialog,
|
||||
DialogTrigger,
|
||||
Heading as AriaHeading,
|
||||
Modal,
|
||||
ModalOverlay
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
}
|
||||
|
||||
function Button(properties: ButtonProperties) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
aria-label='Dialog'
|
||||
className={cn(
|
||||
'w-fit text-sm rounded-lg px-4 py-2',
|
||||
'bg-main-700 dark:bg-main-800 text-white',
|
||||
'hover:bg-main-800 dark:hover:bg-main-700',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.className
|
||||
)}
|
||||
// If control is passed, set the state value
|
||||
onPress={properties.control ? () => {
|
||||
properties.control?.[1](true)
|
||||
} : undefined}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type ActionProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly variant: 'cancel' | 'confirm';
|
||||
}
|
||||
|
||||
function Action(properties: ActionProperties) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
type={properties.variant === 'confirm' ? 'submit' : 'button'}
|
||||
className={cn(
|
||||
'px-4 py-2 rounded-lg',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.variant === 'cancel'
|
||||
? 'text-ui-700 dark:text-ui-300'
|
||||
: 'text-ui-300 dark:text-ui-300',
|
||||
properties.variant === 'confirm'
|
||||
? 'bg-main-700 dark:bg-main-700 pressed:bg-main-800 dark:pressed:bg-main-800'
|
||||
: 'bg-ui-200 dark:bg-ui-800 pressed:bg-ui-300 dark:pressed:bg-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Title(properties: Parameters<typeof AriaHeading>[0]) {
|
||||
return (
|
||||
<AriaHeading
|
||||
{...properties}
|
||||
slot='title'
|
||||
className={cn(
|
||||
'text-lg font-semibold leading-6 mb-5',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
{...properties}
|
||||
className={cn(
|
||||
'text-base leading-6 my-0',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type PanelProperties = {
|
||||
readonly children: (close: () => void) => ReactNode;
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
readonly className?: string;
|
||||
}
|
||||
|
||||
function Panel({ children, control, className }: PanelProperties) {
|
||||
return (
|
||||
<ModalOverlay
|
||||
aria-hidden='true'
|
||||
className={cn(
|
||||
'fixed inset-0 h-screen w-screen z-50 bg-black/30',
|
||||
'flex items-center justify-center dark:bg-black/70',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:duration-200 entering:ease-out',
|
||||
'exiting:fade-out exiting:duration-100 exiting:ease-in',
|
||||
className
|
||||
)}
|
||||
isOpen={control ? control[0] : undefined}
|
||||
onOpenChange={control ? control[1] : undefined}
|
||||
>
|
||||
<Modal
|
||||
className={cn(
|
||||
'w-full max-w-md overflow-hidden rounded-xl p-4',
|
||||
'bg-ui-50 dark:bg-ui-900 shadow-lg',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'dark:border dark:border-ui-700',
|
||||
'entering:zoom-in-95 entering:ease-out entering:duration-200',
|
||||
'exiting:zoom-out-95 exiting:ease-in exiting:duration-100'
|
||||
)}
|
||||
>
|
||||
<AriaDialog role='alertdialog' className='outline-none relative'>
|
||||
{({ close }) => children(close)}
|
||||
</AriaDialog>
|
||||
</Modal>
|
||||
</ModalOverlay>
|
||||
)
|
||||
}
|
||||
|
||||
type DialogProperties = {
|
||||
readonly children: ReactNode;
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
}
|
||||
|
||||
function Dialog({ children, control }: DialogProperties) {
|
||||
if (control) {
|
||||
return children
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogTrigger>
|
||||
{children}
|
||||
</DialogTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Dialog, { Button, Title, Text, Panel, Action })
|
||||
@@ -0,0 +1,46 @@
|
||||
import { AlertIcon } from '@primer/octicons-react'
|
||||
import { isRouteErrorResponse, useRouteError } from '@remix-run/react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Card from './Card'
|
||||
import Code from './Code'
|
||||
|
||||
type Properties = {
|
||||
readonly type?: 'full' | 'embedded';
|
||||
}
|
||||
|
||||
export function ErrorPopup({ type = 'full' }: Properties) {
|
||||
const error = useRouteError()
|
||||
const routing = isRouteErrorResponse(error)
|
||||
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
type === 'embedded'
|
||||
? 'pointer-events-none mt-24'
|
||||
: 'fixed inset-0 h-screen w-screen z-50'
|
||||
)}
|
||||
>
|
||||
<Card>
|
||||
<div className='flex items-center justify-between'>
|
||||
<Card.Title className='text-3xl mb-0'>
|
||||
{routing ? error.status : 'Error'}
|
||||
</Card.Title>
|
||||
<AlertIcon className='w-12 h-12 text-red-500'/>
|
||||
</div>
|
||||
<Card.Text className='mt-4 text-lg'>
|
||||
{routing ? (
|
||||
error.statusText
|
||||
) : (
|
||||
<Code>
|
||||
{message}
|
||||
</Code>
|
||||
)}
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
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?: {
|
||||
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" />} />
|
||||
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
|
||||
{data?.config.read
|
||||
? (
|
||||
<>
|
||||
<TabLink to="/dns" name="DNS" icon={<GlobeIcon className="w-4 h-4" />} />
|
||||
<TabLink to="/settings" name="Settings" icon={<GearIcon className="w-4 h-4" />} />
|
||||
</>
|
||||
)
|
||||
: undefined}
|
||||
</nav>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { LinkExternalIcon } from '@primer/octicons-react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
to: string
|
||||
name: string
|
||||
children: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function Link({ to, name: alt, children, className }: Props) {
|
||||
return (
|
||||
<a
|
||||
href={to}
|
||||
aria-label={alt}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-x-1',
|
||||
'text-blue-500 hover:text-blue-700',
|
||||
'dark:text-blue-400 dark:hover:text-blue-300',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
<LinkExternalIcon className="h-3 w-3" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { type Dispatch, type ReactNode, type SetStateAction } from 'react'
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Menu as AriaMenu,
|
||||
MenuItem,
|
||||
MenuTrigger,
|
||||
Popover
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
function Button(properties: Parameters<typeof AriaButton>[0]) {
|
||||
return (
|
||||
<AriaButton
|
||||
{...properties}
|
||||
className={cn(
|
||||
'outline-none',
|
||||
properties.className
|
||||
)}
|
||||
aria-label='Menu'
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Items(properties: Parameters<typeof AriaMenu>[0]) {
|
||||
return (
|
||||
<Popover className={cn(
|
||||
'mt-2 rounded-md',
|
||||
'bg-ui-50 dark:bg-ui-800',
|
||||
'overflow-hidden z-50',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:zoom-in-95',
|
||||
'exiting:fade-out exiting:zoom-out-95',
|
||||
'fill-mode-forwards origin-left-right'
|
||||
)}
|
||||
>
|
||||
<AriaMenu
|
||||
{...properties}
|
||||
className={cn(
|
||||
'outline-none',
|
||||
'divide-y divide-ui-200 dark:divide-ui-600',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</AriaMenu>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
||||
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>];
|
||||
}
|
||||
|
||||
function ItemButton(properties: ButtonProperties) {
|
||||
return (
|
||||
<MenuItem className='outline-none'>
|
||||
<AriaButton
|
||||
{...properties}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none text-left',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
aria-label='Menu Dialog'
|
||||
// If control is passed, set the state value
|
||||
onPress={event => {
|
||||
properties.onPress?.(event)
|
||||
properties.control?.[1](true)
|
||||
}}
|
||||
/>
|
||||
</MenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
function Item(properties: Parameters<typeof MenuItem>[0]) {
|
||||
return (
|
||||
<MenuItem
|
||||
{...properties}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
properties.className
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function Menu({ children }: { readonly children: ReactNode }) {
|
||||
return (
|
||||
<MenuTrigger>
|
||||
{children}
|
||||
</MenuTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Menu, { Button, Item, ItemButton, Items })
|
||||
@@ -0,0 +1,23 @@
|
||||
import { InfoIcon } from '@primer/octicons-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export default function Notice({ children, className }: Props) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'p-4 rounded-md w-full flex items-center gap-3',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { ChevronDownIcon } from '@primer/octicons-react'
|
||||
import { Dispatch, ReactNode, SetStateAction } from 'react'
|
||||
import {
|
||||
Button,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
Popover,
|
||||
Select as AriaSelect,
|
||||
SelectValue,
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type SelectProps = Parameters<typeof AriaSelect>[0] & {
|
||||
readonly label: string
|
||||
readonly state?: [string, Dispatch<SetStateAction<string>>]
|
||||
readonly children: ReactNode
|
||||
}
|
||||
|
||||
function Select(props: SelectProps) {
|
||||
return (
|
||||
<AriaSelect
|
||||
{...props}
|
||||
aria-label={props.label}
|
||||
selectedKey={props.state?.[0]}
|
||||
onSelectionChange={(key) => {
|
||||
props.state?.[1](key.toString())
|
||||
}}
|
||||
className={cn(
|
||||
'block w-full rounded-lg my-1',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'bg-white dark:bg-ui-800 dark:text-ui-300',
|
||||
'focus-within:outline-6',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
<Button className={cn(
|
||||
'w-full flex items-center justify-between',
|
||||
'px-2.5 py-1.5 rounded-lg',
|
||||
)}
|
||||
>
|
||||
<SelectValue />
|
||||
<ChevronDownIcon className="w-4 h-4" aria-hidden="true" />
|
||||
</Button>
|
||||
<Popover
|
||||
className={cn(
|
||||
'mt-2 rounded-md w-[var(--trigger-width)]',
|
||||
'bg-ui-100 dark:bg-ui-800 shadow-sm',
|
||||
'overflow-hidden z-50',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'entering:animate-in exiting:animate-out',
|
||||
'entering:fade-in entering:zoom-in-95',
|
||||
'exiting:fade-out exiting:zoom-out-95',
|
||||
'fill-mode-forwards origin-left-right',
|
||||
)}
|
||||
>
|
||||
<ListBox>
|
||||
{props.children}
|
||||
</ListBox>
|
||||
</Popover>
|
||||
</AriaSelect>
|
||||
)
|
||||
}
|
||||
|
||||
type ItemProps = Parameters<typeof ListBoxItem>[0]
|
||||
|
||||
function Item(props: ItemProps) {
|
||||
return (
|
||||
<ListBoxItem
|
||||
{...props}
|
||||
className={cn(
|
||||
'px-4 py-2 w-full outline-none w-full',
|
||||
'hover:bg-ui-200 dark:hover:bg-ui-700',
|
||||
props.className,
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</ListBoxItem>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Select, { Item })
|
||||
@@ -0,0 +1,23 @@
|
||||
import clsx from 'clsx'
|
||||
|
||||
type Properties = {
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export default function Spinner(properties: Properties) {
|
||||
return (
|
||||
<div className={clsx('mr-1.5 inline-block align-middle mb-0.5', properties.className)}>
|
||||
<div
|
||||
className={clsx(
|
||||
'animate-spin rounded-full w-full h-full',
|
||||
'border-2 border-current border-t-transparent',
|
||||
properties.className
|
||||
)}
|
||||
role='status'
|
||||
>
|
||||
<span className='sr-only'>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import clsx from 'clsx'
|
||||
import { type HTMLProps } from 'react'
|
||||
|
||||
type Properties = HTMLProps<SVGElement> & {
|
||||
readonly isOnline: boolean;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/no-keyword-prefix
|
||||
export default function StatusCircle({ isOnline, className }: Properties) {
|
||||
return (
|
||||
<svg
|
||||
className={clsx(
|
||||
className,
|
||||
isOnline
|
||||
? 'text-green-700 dark:text-green-400'
|
||||
: 'text-gray-300 dark:text-gray-500'
|
||||
)}
|
||||
viewBox='0 0 24 24'
|
||||
fill='currentColor'
|
||||
>
|
||||
<circle cx='12' cy='12' r='8'/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Switch as AriaSwitch } from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type SwitchProperties = Parameters<typeof AriaSwitch>[0] & {
|
||||
readonly label: string;
|
||||
}
|
||||
|
||||
export default function Switch(properties: SwitchProperties) {
|
||||
return (
|
||||
<AriaSwitch
|
||||
{...properties}
|
||||
aria-label={properties.label}
|
||||
className='group flex gap-2 items-center'
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-[26px] w-[44px] p-[4px] shrink-0',
|
||||
'rounded-full outline-none group-focus-visible:ring-2',
|
||||
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
|
||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
<span className={cn(
|
||||
'h-[18px] w-[18px] transform rounded-full',
|
||||
'bg-white transition duration-100 ease-in-out',
|
||||
'translate-x-0 group-selected:translate-x-[100%]'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</AriaSwitch>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NavLink } from '@remix-run/react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type Properties = {
|
||||
readonly name: string;
|
||||
readonly to: string;
|
||||
readonly icon: ReactNode;
|
||||
}
|
||||
|
||||
export default function TabLink({ name, to, icon }: Properties) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
prefetch='intent'
|
||||
className={({ isActive }) => cn(
|
||||
'border-b-2 py-1.5',
|
||||
isActive ? 'border-white' : 'border-transparent'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap',
|
||||
'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md'
|
||||
)}
|
||||
>
|
||||
{icon} {name}
|
||||
</div>
|
||||
</NavLink>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import clsx from 'clsx'
|
||||
import { type HTMLProps } from 'react'
|
||||
|
||||
function TableList(properties: HTMLProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...properties}
|
||||
className={clsx(
|
||||
'border border-gray-300 rounded-lg overflow-clip',
|
||||
'dark:border-zinc-700 dark:text-gray-300',
|
||||
// 'dark:bg-zinc-800',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Item(properties: HTMLProps<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
{...properties}
|
||||
className={clsx(
|
||||
|
||||
'flex items-center justify-between px-3 py-2',
|
||||
'border-b border-gray-200 last:border-b-0',
|
||||
'dark:border-zinc-800',
|
||||
properties.className
|
||||
)}
|
||||
>
|
||||
{properties.children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(TableList, { Item })
|
||||
@@ -0,0 +1,38 @@
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
import {
|
||||
Input,
|
||||
TextField as AriaTextField
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
|
||||
readonly label: string;
|
||||
readonly placeholder: string;
|
||||
readonly state?: [string, Dispatch<SetStateAction<string>>];
|
||||
}
|
||||
|
||||
export default function TextField(properties: TextFieldProperties) {
|
||||
return (
|
||||
<AriaTextField
|
||||
{...properties}
|
||||
aria-label={properties.label}
|
||||
className='w-full'
|
||||
>
|
||||
<Input
|
||||
placeholder={properties.placeholder}
|
||||
value={properties.state?.[0]}
|
||||
name={properties.name}
|
||||
className={cn(
|
||||
'block px-2.5 py-1.5 w-full rounded-lg my-1',
|
||||
'border border-ui-200 dark:border-ui-600',
|
||||
'dark:bg-ui-800 dark:text-ui-300',
|
||||
properties.className
|
||||
)}
|
||||
onChange={event => {
|
||||
properties.state?.[1](event.target.value)
|
||||
}}
|
||||
/>
|
||||
</AriaTextField>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { XIcon } from '@primer/octicons-react'
|
||||
import { type AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
|
||||
import { ToastQueue, type ToastState, useToastQueue } from '@react-stately/toast'
|
||||
import { type ReactNode, useRef } from 'react'
|
||||
import { Button } from 'react-aria-components'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type ToastProperties = AriaToastProps<ReactNode> & {
|
||||
readonly state: ToastState<ReactNode>;
|
||||
}
|
||||
|
||||
function Toast({ state, ...properties }: ToastProperties) {
|
||||
const reference = useRef(null)
|
||||
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
|
||||
|
||||
return (
|
||||
<div
|
||||
{...toastProps}
|
||||
ref={reference}
|
||||
className={cn(
|
||||
'bg-main-700 dark:bg-main-800 rounded-lg',
|
||||
'text-main-100 dark:text-main-200 z-50',
|
||||
'border border-main-600 dark:border-main-700',
|
||||
'flex items-center justify-between p-3 pl-4 w-80'
|
||||
)}
|
||||
>
|
||||
<div {...titleProps}>{properties.toast.content}</div>
|
||||
<Button
|
||||
{...closeButtonProps}
|
||||
className={cn(
|
||||
'outline-none rounded-full p-1',
|
||||
'hover:bg-main-600 dark:hover:bg-main-700'
|
||||
)}
|
||||
>
|
||||
<XIcon className='w-4 h-4'/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const toasts = new ToastQueue<ReactNode>({
|
||||
maxVisibleToasts: 5
|
||||
})
|
||||
|
||||
export function toast(text: string) {
|
||||
return toasts.add(text, { timeout: 5000 })
|
||||
}
|
||||
|
||||
export function Toaster() {
|
||||
const reference = useRef(null)
|
||||
const state = useToastQueue(toasts)
|
||||
const { regionProps } = useToastRegion({}, state, reference)
|
||||
|
||||
return (
|
||||
<ClientOnly>
|
||||
{() => createPortal(
|
||||
state.visibleToasts.length >= 0 ? (
|
||||
<div
|
||||
className={cn(
|
||||
'fixed bottom-4 right-4',
|
||||
'flex flex-col gap-4'
|
||||
)}
|
||||
{...regionProps}
|
||||
ref={reference}
|
||||
>
|
||||
{state.visibleToasts.map(toast => (
|
||||
<Toast key={toast.key} toast={toast} state={state}/>
|
||||
))}
|
||||
</div>
|
||||
) : undefined,
|
||||
document.body
|
||||
)}
|
||||
</ClientOnly>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ReactNode } from 'react'
|
||||
import {
|
||||
Button as AriaButton,
|
||||
Tooltip as AriaTooltip,
|
||||
TooltipTrigger,
|
||||
} from 'react-aria-components'
|
||||
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
children: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
function Tooltip({ children }: Props) {
|
||||
return (
|
||||
<TooltipTrigger delay={0}>
|
||||
{children}
|
||||
</TooltipTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function Button(props: Parameters<typeof AriaButton>[0]) {
|
||||
return (
|
||||
<AriaButton {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function Body({ children, className }: Props) {
|
||||
return (
|
||||
<AriaTooltip className={cn(
|
||||
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
||||
'bg-white dark:bg-ui-900 drop-shadow-sm',
|
||||
'border border-gray-200 dark:border-zinc-700',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</AriaTooltip>
|
||||
)
|
||||
}
|
||||
|
||||
export default Object.assign(Tooltip, { Button, Body })
|
||||
@@ -0,0 +1,62 @@
|
||||
import { PassThrough } from 'node:stream'
|
||||
|
||||
import type { AppLoadContext, EntryContext } from '@remix-run/node'
|
||||
import { createReadableStreamFromReadable } from '@remix-run/node'
|
||||
import { RemixServer } from '@remix-run/react'
|
||||
import { isbot } from 'isbot'
|
||||
import { renderToPipeableStream } from 'react-dom/server'
|
||||
|
||||
import { loadContext } from './utils/config/headplane'
|
||||
|
||||
await loadContext()
|
||||
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
remixContext: EntryContext,
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
_loadContext: AppLoadContext,
|
||||
) {
|
||||
const ua = request.headers.get('user-agent')
|
||||
const isBot = ua ? isbot(ua) : false
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<RemixServer
|
||||
context={remixContext}
|
||||
url={request.url}
|
||||
abortDelay={5000}
|
||||
/>,
|
||||
{
|
||||
[isBot ? 'onAllReady' : 'onShellReady']() {
|
||||
shellRendered = true
|
||||
const body = new PassThrough()
|
||||
const stream = createReadableStreamFromReadable(body)
|
||||
responseHeaders.set('Content-Type', 'text/html')
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
)
|
||||
|
||||
pipe(body)
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error as Error)
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500
|
||||
if (shellRendered) {
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
setTimeout(abort, 5000)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { access, constants } from 'node:fs/promises'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
import { Client } from 'undici'
|
||||
|
||||
import { HeadscaleError, pull } from '~/utils/headscale'
|
||||
import log from '~/utils/log'
|
||||
|
||||
import { createIntegration } from './integration'
|
||||
|
||||
interface Context {
|
||||
client: Client | undefined
|
||||
container: string | undefined
|
||||
maxAttempts: number
|
||||
}
|
||||
|
||||
export default createIntegration<Context>({
|
||||
name: 'Docker',
|
||||
context: {
|
||||
client: undefined,
|
||||
container: undefined,
|
||||
maxAttempts: 10,
|
||||
},
|
||||
isAvailable: async (context) => {
|
||||
// Check for the HEADSCALE_CONTAINER environment variable first
|
||||
// to avoid unnecessary fetching of the Docker socket
|
||||
log.debug('INTG', 'Checking Docker integration availability')
|
||||
context.container = process.env.HEADSCALE_CONTAINER
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
|
||||
if (!context.container || context.container.length === 0) {
|
||||
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable')
|
||||
return false
|
||||
}
|
||||
|
||||
log.info('INTG', 'Using container: %s', context.container)
|
||||
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
|
||||
let url: URL | undefined
|
||||
|
||||
try {
|
||||
url = new URL(path)
|
||||
} catch {
|
||||
log.error('INTG', 'Invalid Docker socket path: %s', path)
|
||||
return false
|
||||
}
|
||||
|
||||
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
|
||||
log.error('INTG', 'Invalid Docker socket protocol: %s',
|
||||
url.protocol,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// The API is available as an HTTP endpoint and this
|
||||
// will simplify the fetching logic in undici
|
||||
if (url.protocol === 'tcp:') {
|
||||
// Apparently setting url.protocol doesn't work anymore?
|
||||
const fetchU = url.href.replace(url.protocol, 'http:')
|
||||
|
||||
try {
|
||||
log.info('INTG', 'Checking API: %s', fetchU)
|
||||
await fetch(new URL('/v1.30/version', fetchU).href)
|
||||
} catch (error) {
|
||||
log.debug('INTG', 'Failed to connect to Docker API', error)
|
||||
log.error('INTG', 'Failed to connect to Docker API')
|
||||
return false
|
||||
}
|
||||
|
||||
context.client = new Client(fetchU)
|
||||
}
|
||||
|
||||
// Check if the socket is accessible
|
||||
if (url.protocol === 'unix:') {
|
||||
try {
|
||||
log.info('INTG', 'Checking socket: %s',
|
||||
url.pathname,
|
||||
)
|
||||
await access(url.pathname, constants.R_OK)
|
||||
} catch (error) {
|
||||
log.debug('INTG', 'Failed to access Docker socket: %s', error)
|
||||
log.error('INTG', 'Failed to access Docker socket: %s',
|
||||
path,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
context.client = new Client('http://localhost', {
|
||||
socketPath: url.pathname,
|
||||
})
|
||||
}
|
||||
|
||||
return context.client !== undefined
|
||||
},
|
||||
|
||||
onConfigChange: async (context) => {
|
||||
if (!context.client || !context.container) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info('INTG', 'Restarting Headscale via Docker')
|
||||
|
||||
let attempts = 0
|
||||
while (attempts <= context.maxAttempts) {
|
||||
log.debug(
|
||||
'INTG', 'Restarting container: %s (attempt %d)',
|
||||
context.container,
|
||||
attempts,
|
||||
)
|
||||
|
||||
const response = await context.client.request({
|
||||
method: 'POST',
|
||||
path: `/v1.30/containers/${context.container}/restart`,
|
||||
})
|
||||
|
||||
if (response.statusCode !== 204) {
|
||||
if (attempts < context.maxAttempts) {
|
||||
attempts++
|
||||
await setTimeout(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
const stringCode = response.statusCode.toString()
|
||||
const body = await response.body.text()
|
||||
throw new Error(`API request failed: ${stringCode} ${body}`)
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
attempts = 0
|
||||
while (attempts <= context.maxAttempts) {
|
||||
try {
|
||||
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts)
|
||||
await pull('v1', '')
|
||||
return
|
||||
} catch (error) {
|
||||
if (error instanceof HeadscaleError && error.status === 401) {
|
||||
break
|
||||
}
|
||||
|
||||
if (error instanceof HeadscaleError && error.status === 404) {
|
||||
break
|
||||
}
|
||||
|
||||
if (attempts < context.maxAttempts) {
|
||||
attempts++
|
||||
await setTimeout(1000)
|
||||
continue
|
||||
}
|
||||
|
||||
throw new Error(`Missed restart deadline for ${context.container}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
import log from '~/utils/log'
|
||||
|
||||
import dockerIntegration from './docker'
|
||||
import { IntegrationFactory } from './integration'
|
||||
import kubernetesIntegration from './kubernetes'
|
||||
import procIntegration from './proc'
|
||||
|
||||
export * from './integration'
|
||||
|
||||
export async function loadIntegration() {
|
||||
let integration = process.env.HEADSCALE_INTEGRATION
|
||||
?.trim()
|
||||
.toLowerCase()
|
||||
|
||||
// Old HEADSCALE_CONTAINER variable upgrade path
|
||||
// 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 docker
|
||||
if (!integration && process.env.HEADSCALE_CONTAINER) {
|
||||
integration = 'docker'
|
||||
}
|
||||
|
||||
if (!integration) {
|
||||
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION')
|
||||
return
|
||||
}
|
||||
|
||||
let integrationFactory: IntegrationFactory | undefined
|
||||
switch (integration.toLowerCase().trim()) {
|
||||
case 'docker': {
|
||||
integrationFactory = dockerIntegration
|
||||
break
|
||||
}
|
||||
|
||||
case 'proc':
|
||||
case 'native':
|
||||
case 'linux': {
|
||||
integrationFactory = procIntegration
|
||||
break
|
||||
}
|
||||
|
||||
case 'kubernetes':
|
||||
case 'k8s': {
|
||||
integrationFactory = kubernetesIntegration
|
||||
break
|
||||
}
|
||||
|
||||
default: {
|
||||
log.error('INTG', 'Unknown integration: %s', integration)
|
||||
throw new Error(`Unknown integration: ${integration}`)
|
||||
}
|
||||
}
|
||||
|
||||
log.info('INTG', 'Loading integration: %s', integration)
|
||||
try {
|
||||
const res = await integrationFactory.isAvailable(
|
||||
integrationFactory.context,
|
||||
)
|
||||
if (!res) {
|
||||
log.error('INTG', 'Integration %s is not available',
|
||||
integration,
|
||||
)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to load integration %s: %s',
|
||||
integration,
|
||||
error,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
log.info('INTG', 'Loaded integration: %s', integration)
|
||||
return integrationFactory
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export interface IntegrationFactory<T = any> {
|
||||
name: string
|
||||
context: T
|
||||
isAvailable: (context: T) => Promise<boolean> | boolean
|
||||
onConfigChange?: (context: T) => Promise<void> | void
|
||||
}
|
||||
|
||||
export function createIntegration<T>(
|
||||
options: IntegrationFactory<T>,
|
||||
) {
|
||||
return options
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { platform } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { kill } from 'node:process'
|
||||
|
||||
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node'
|
||||
|
||||
import log from '~/utils/log'
|
||||
|
||||
import { createIntegration } from './integration'
|
||||
|
||||
interface Context {
|
||||
pid: number | undefined
|
||||
}
|
||||
|
||||
export default createIntegration<Context>({
|
||||
name: 'Kubernetes (k8s)',
|
||||
context: {
|
||||
pid: undefined,
|
||||
},
|
||||
isAvailable: async (context) => {
|
||||
if (platform() !== 'linux') {
|
||||
log.error('INTG', 'Kubernetes is only available on Linux')
|
||||
return false
|
||||
}
|
||||
|
||||
const svcRoot = Config.SERVICEACCOUNT_ROOT
|
||||
try {
|
||||
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot)
|
||||
const files = await readdir(svcRoot)
|
||||
if (files.length === 0) {
|
||||
log.error('INTG', 'Kubernetes service account not found')
|
||||
return false
|
||||
}
|
||||
|
||||
const mappedFiles = new Set(files.map(file => join(svcRoot, file)))
|
||||
const expectedFiles = [
|
||||
Config.SERVICEACCOUNT_CA_PATH,
|
||||
Config.SERVICEACCOUNT_TOKEN_PATH,
|
||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||
]
|
||||
|
||||
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '))
|
||||
if (!expectedFiles.every(file => mappedFiles.has(file))) {
|
||||
log.error('INTG', 'Malformed Kubernetes service account')
|
||||
return false
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to access %s: %s', svcRoot, error)
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot)
|
||||
const namespace = await readFile(
|
||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||
'utf8',
|
||||
)
|
||||
|
||||
// Some very ugly nesting but it's necessary
|
||||
if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') {
|
||||
log.warn('INTG', 'Skipping strict Pod status check')
|
||||
} else {
|
||||
const pod = process.env.POD_NAME
|
||||
if (!pod) {
|
||||
log.error('INTG', 'Missing POD_NAME variable')
|
||||
return false
|
||||
}
|
||||
|
||||
if (pod.trim().length === 0) {
|
||||
log.error('INTG', 'Pod name is empty')
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Checking Kubernetes pod %s in namespace %s',
|
||||
pod,
|
||||
namespace,
|
||||
)
|
||||
|
||||
try {
|
||||
log.debug('INTG', 'Attempgin to get cluster KubeConfig')
|
||||
const kc = new KubeConfig()
|
||||
kc.loadFromCluster()
|
||||
|
||||
const cluster = kc.getCurrentCluster()
|
||||
if (!cluster) {
|
||||
log.error('INTG', 'Malformed kubeconfig')
|
||||
return false
|
||||
}
|
||||
|
||||
log.info('INTG', 'Service account connected to %s (%s)',
|
||||
cluster.name,
|
||||
cluster.server,
|
||||
)
|
||||
|
||||
const kCoreV1Api = kc.makeApiClient(CoreV1Api)
|
||||
|
||||
log.info('INTG', 'Checking pod %s in namespace %s (%s)',
|
||||
pod,
|
||||
namespace,
|
||||
kCoreV1Api.basePath,
|
||||
)
|
||||
|
||||
log.debug('INTG', 'Reading pod info for %s', pod)
|
||||
const { response, body } = await kCoreV1Api.readNamespacedPod(
|
||||
pod,
|
||||
namespace,
|
||||
)
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
log.error('INTG', 'Failed to read pod info: http %d',
|
||||
response.statusCode,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Got pod info: %o', body.spec)
|
||||
const shared = body.spec?.shareProcessNamespace
|
||||
if (shared === undefined) {
|
||||
log.error(
|
||||
'INTG',
|
||||
'Pod does not have spec.shareProcessNamespace set',
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!shared) {
|
||||
log.error(
|
||||
'INTG',
|
||||
'Pod has set but disabled spec.shareProcessNamespace',
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
log.info('INTG', 'Pod %s enabled shared processes', pod)
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to read pod info: %s', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Looking for namespaced process in /proc')
|
||||
const dir = resolve('/proc')
|
||||
try {
|
||||
const subdirs = await readdir(dir)
|
||||
const promises = subdirs.map(async (dir) => {
|
||||
const pid = Number.parseInt(dir, 10)
|
||||
|
||||
if (Number.isNaN(pid)) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = join('/proc', dir, 'cmdline')
|
||||
try {
|
||||
log.debug('INTG', 'Reading %s', path)
|
||||
const data = await readFile(path, 'utf8')
|
||||
if (data.includes('headscale')) {
|
||||
return pid
|
||||
}
|
||||
} catch (error) {
|
||||
log.debug('INTG', 'Failed to read %s: %s', path, error)
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(promises)
|
||||
const pids = []
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
pids.push(result.value)
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Found Headscale processes: %o', pids)
|
||||
if (pids.length > 1) {
|
||||
log.error('INTG', 'Found %d Headscale processes: %s',
|
||||
pids.length,
|
||||
pids.join(', '),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if (pids.length === 0) {
|
||||
log.error('INTG', 'Could not find Headscale process')
|
||||
return false
|
||||
}
|
||||
|
||||
context.pid = pids[0]
|
||||
log.info('INTG', 'Found Headscale process with PID: %d', context.pid)
|
||||
return true
|
||||
} catch {
|
||||
log.error('INTG', 'Failed to read /proc')
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
onConfigChange: (context) => {
|
||||
if (!context.pid) {
|
||||
return
|
||||
}
|
||||
|
||||
log.info('INTG', 'Sending SIGTERM to Headscale')
|
||||
kill(context.pid, 'SIGTERM')
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { platform } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { kill } from 'node:process'
|
||||
|
||||
import log from '~/utils/log'
|
||||
|
||||
import { createIntegration } from './integration'
|
||||
|
||||
interface Context {
|
||||
pid: number | undefined
|
||||
}
|
||||
|
||||
export default createIntegration<Context>({
|
||||
name: 'Native Linux (/proc)',
|
||||
context: {
|
||||
pid: undefined,
|
||||
},
|
||||
isAvailable: async (context) => {
|
||||
if (platform() !== 'linux') {
|
||||
log.error('INTG', '/proc is only available on Linux')
|
||||
return false
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Checking /proc for Headscale process')
|
||||
const dir = resolve('/proc')
|
||||
try {
|
||||
const subdirs = await readdir(dir)
|
||||
const promises = subdirs.map(async (dir) => {
|
||||
const pid = Number.parseInt(dir, 10)
|
||||
|
||||
if (Number.isNaN(pid)) {
|
||||
return
|
||||
}
|
||||
|
||||
const path = join('/proc', dir, 'cmdline')
|
||||
try {
|
||||
log.debug('INTG', 'Reading %s', path)
|
||||
const data = await readFile(path, 'utf8')
|
||||
if (data.includes('headscale')) {
|
||||
return pid
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to read %s: %s', path, error)
|
||||
}
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(promises)
|
||||
const pids = []
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
pids.push(result.value)
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Found Headscale processes: %o', pids)
|
||||
if (pids.length > 1) {
|
||||
log.error('INTG', 'Found %d Headscale processes: %s',
|
||||
pids.length,
|
||||
pids.join(', '),
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
if (pids.length === 0) {
|
||||
log.error('INTG', 'Could not find Headscale process')
|
||||
return false
|
||||
}
|
||||
|
||||
context.pid = pids[0]
|
||||
log.info('INTG', 'Found Headscale process with PID: %d', context.pid)
|
||||
return true
|
||||
} catch {
|
||||
log.error('INTG', 'Failed to read /proc')
|
||||
return false
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { LinksFunction, MetaFunction } from '@remix-run/node'
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
} from '@remix-run/react'
|
||||
|
||||
import { ErrorPopup } from '~/components/Error'
|
||||
import { Toaster } from '~/components/Toaster'
|
||||
import stylesheet from '~/tailwind.css?url'
|
||||
|
||||
export const meta: MetaFunction = () => [
|
||||
{ title: 'Headplane' },
|
||||
{ name: 'description', content: 'A frontend for the headscale coordination server' },
|
||||
]
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: 'stylesheet', href: stylesheet },
|
||||
]
|
||||
|
||||
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body className="overscroll-none dark:bg-ui-950 dark:text-ui-50">
|
||||
{children}
|
||||
<Toaster />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ErrorPopup />
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import Editor, { DiffEditor, Monaco } from '@monaco-editor/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
|
||||
import Fallback from '~/routes/_data.acls._index/fallback'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
variant: 'edit' | 'diff'
|
||||
language: 'json' | 'yaml'
|
||||
state: [string, (value: string) => void]
|
||||
policy?: string
|
||||
isDisabled?: boolean
|
||||
}
|
||||
|
||||
function monacoCallback(monaco: Monaco) {
|
||||
monaco.languages.json.jsonDefaults.setDiagnosticsOptions({
|
||||
validate: true,
|
||||
allowComments: true,
|
||||
schemas: [],
|
||||
enableSchemaRequest: true,
|
||||
trailingCommas: 'ignore',
|
||||
})
|
||||
|
||||
monaco.languages.register({ id: 'json' })
|
||||
monaco.languages.register({ id: 'yaml' })
|
||||
}
|
||||
|
||||
export default function MonacoEditor({ variant, language, state, policy, isDisabled }: Props) {
|
||||
const [light, setLight] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
||||
setLight(theme.matches)
|
||||
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches)
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn(
|
||||
'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">
|
||||
<ClientOnly fallback={<Fallback acl={state[0]} />}>
|
||||
{() => variant === 'edit'
|
||||
? (
|
||||
<Editor
|
||||
height="100%"
|
||||
language={language}
|
||||
theme={light ? 'light' : 'vs-dark'}
|
||||
value={state[0]}
|
||||
onChange={(updated) => {
|
||||
if (!updated) {
|
||||
return
|
||||
}
|
||||
|
||||
if (updated !== state[0]) {
|
||||
state[1](updated)
|
||||
}
|
||||
}}
|
||||
loading={<Fallback acl={state[0]} />}
|
||||
beforeMount={monacoCallback}
|
||||
options={{
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: false },
|
||||
fontSize: 14,
|
||||
readOnly: isDisabled,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<DiffEditor
|
||||
height="100%"
|
||||
language={language}
|
||||
theme={light ? 'light' : 'vs-dark'}
|
||||
original={policy}
|
||||
modified={state[0]}
|
||||
loading={<Fallback acl={state[0]} />}
|
||||
beforeMount={monacoCallback}
|
||||
options={{
|
||||
wordWrap: 'on',
|
||||
minimap: { enabled: false },
|
||||
fontSize: 13,
|
||||
readOnly: isDisabled,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface FallbackProps {
|
||||
readonly acl: string
|
||||
}
|
||||
|
||||
export default function Fallback({ acl }: FallbackProps) {
|
||||
return (
|
||||
<div className="inline-block relative w-full h-editor">
|
||||
<Spinner className="w-4 h-4 absolute p-2" />
|
||||
<textarea
|
||||
readOnly
|
||||
className={cn(
|
||||
'w-full h-editor font-mono resize-none',
|
||||
'text-sm text-gray-600 dark:text-gray-300',
|
||||
'bg-ui-100 dark:bg-ui-800',
|
||||
'pl-16 pr-8 pt-0.5 leading-snug',
|
||||
)}
|
||||
value={acl}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
||||
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useFetcher, useLoaderData } from '@remix-run/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
|
||||
import { setTimeout } from 'node:timers/promises'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Code from '~/components/Code'
|
||||
import Link from '~/components/Link'
|
||||
import Notice from '~/components/Notice'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { HeadscaleError, pull, put } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
|
||||
import Monaco from './editor'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
|
||||
try {
|
||||
const { policy } = await pull<{ policy: string }>(
|
||||
'v1/policy',
|
||||
session.get('hsApiKey')!,
|
||||
)
|
||||
|
||||
try {
|
||||
// We have read access, now do we have write access?
|
||||
// Attempt to set the policy to what we just got
|
||||
await put('v1/policy', session.get('hsApiKey')!, {
|
||||
policy,
|
||||
})
|
||||
|
||||
return {
|
||||
hasAclWrite: true,
|
||||
currentAcl: policy,
|
||||
aclType: 'json',
|
||||
} as const
|
||||
} catch (error) {
|
||||
if (!(error instanceof HeadscaleError)) {
|
||||
throw error
|
||||
}
|
||||
|
||||
if (error.status === 500) {
|
||||
return {
|
||||
hasAclWrite: false,
|
||||
currentAcl: policy,
|
||||
aclType: 'json',
|
||||
} as const
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
hasAclWrite: true,
|
||||
currentAcl: '',
|
||||
aclType: 'json',
|
||||
} as const
|
||||
}
|
||||
|
||||
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 { acl } = await request.json() as { acl: string, api: boolean }
|
||||
try {
|
||||
await put('v1/policy', session.get('hsApiKey')!, {
|
||||
policy: acl,
|
||||
})
|
||||
|
||||
await setTimeout(250)
|
||||
return json({ success: true })
|
||||
} catch (error) {
|
||||
return json({ success: false }, {
|
||||
status: error instanceof HeadscaleError ? error.status : 500,
|
||||
})
|
||||
}
|
||||
|
||||
return json({ success: true })
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<div>
|
||||
<Notice className="mb-4">
|
||||
An ACL policy is not available or an error occurred while trying to fetch it.
|
||||
</Notice>
|
||||
<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>
|
||||
<div>
|
||||
<div className="max-w-prose">
|
||||
<p className="mb-4 text-md">
|
||||
If you are running Headscale 0.23-beta1 or later, the
|
||||
ACL configuration is most likely set to
|
||||
{' '}
|
||||
<Code>file</Code>
|
||||
{' '}
|
||||
mode but the ACL file is not available. In order to
|
||||
resolve this you will either need to correctly set
|
||||
{' '}
|
||||
<Code>policy.path</Code>
|
||||
{' '}
|
||||
in your Headscale configuration or set the
|
||||
{' '}
|
||||
<Code>policy.mode</Code>
|
||||
{' '}
|
||||
to
|
||||
{' '}
|
||||
<Code>database</Code>
|
||||
.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const fetcher = useFetcher<typeof action>()
|
||||
const [acl, setAcl] = useState(data.currentAcl)
|
||||
const [toasted, setToasted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data || toasted) {
|
||||
return
|
||||
}
|
||||
|
||||
if (fetcher.data.success) {
|
||||
toast('Updated tailnet ACL policy')
|
||||
} else {
|
||||
toast('Failed to update tailnet ACL policy')
|
||||
}
|
||||
|
||||
setToasted(true)
|
||||
setAcl(data.currentAcl)
|
||||
}, [fetcher.data, toasted, data.currentAcl])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.hasAclWrite
|
||||
? undefined
|
||||
: (
|
||||
<div className="mb-4">
|
||||
<Notice className="w-fit">
|
||||
The ACL policy is read-only. You can view the current policy
|
||||
but you cannot make changes to it.
|
||||
<br />
|
||||
To resolve this, you need to set the ACL policy mode to
|
||||
database in your Headscale configuration.
|
||||
</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">
|
||||
<Monaco
|
||||
isDisabled={!data.hasAclWrite}
|
||||
variant="edit"
|
||||
language={data.aclType}
|
||||
state={[acl, setAcl]}
|
||||
/>
|
||||
</TabPanel>
|
||||
<TabPanel id="diff">
|
||||
<Monaco
|
||||
variant="diff"
|
||||
language={data.aclType}
|
||||
state={[acl, setAcl]}
|
||||
policy={data.currentAcl}
|
||||
/>
|
||||
</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>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
|
||||
onPress={() => {
|
||||
setToasted(false)
|
||||
fetcher.submit({
|
||||
acl,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{fetcher.state === 'idle'
|
||||
? undefined
|
||||
: (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl || !data.hasAclWrite}
|
||||
onPress={() => { setAcl(data.currentAcl) }}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface Props {
|
||||
records: { name: string, type: 'A', value: string }[]
|
||||
}
|
||||
|
||||
export default function AddDNS({ records }: Props) {
|
||||
const submit = useSubmit()
|
||||
const [name, setName] = useState('')
|
||||
const [ip, setIp] = useState('')
|
||||
|
||||
const isDuplicate = useMemo(() => {
|
||||
if (name.length === 0 || ip.length === 0) return false
|
||||
const lookup = records.find(record => record.name === name)
|
||||
if (!lookup) return false
|
||||
|
||||
return lookup.value === ip
|
||||
}, [records, name, ip])
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>
|
||||
Add DNS record
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Add DNS record
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Enter the domain and IP address for the new DNS record.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!name || !ip) return
|
||||
|
||||
setName('')
|
||||
setIp('')
|
||||
|
||||
submit({
|
||||
'dns.extra_records': [
|
||||
...records,
|
||||
{
|
||||
name,
|
||||
type: 'A',
|
||||
value: ip,
|
||||
},
|
||||
],
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="Domain"
|
||||
placeholder="test.example.com"
|
||||
name="domain"
|
||||
state={[name, setName]}
|
||||
className={cn(
|
||||
'mt-2',
|
||||
isDuplicate && 'outline outline-red-500',
|
||||
)}
|
||||
/>
|
||||
<TextField
|
||||
label="IP Address"
|
||||
placeholder="101.101.101.101"
|
||||
name="ip"
|
||||
state={[ip, setIp]}
|
||||
className={cn(
|
||||
isDuplicate && 'outline outline-red-500',
|
||||
)}
|
||||
/>
|
||||
{isDuplicate
|
||||
? (
|
||||
<p className="text-sm opacity-50">
|
||||
A record with the domain name
|
||||
{' '}
|
||||
<Code>{name}</Code>
|
||||
{' '}
|
||||
and IP address
|
||||
{' '}
|
||||
<Code>{ip}</Code>
|
||||
{' '}
|
||||
already exists.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-8">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
isDisabled={isDuplicate}
|
||||
>
|
||||
Add
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -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.nameservers.split': splitNs,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
} else {
|
||||
const globalNs = nameservers.global
|
||||
globalNs.push(ns)
|
||||
|
||||
submit({
|
||||
'dns.nameservers.global': globalNs,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}
|
||||
|
||||
setNs('')
|
||||
setDomain('')
|
||||
setSplit(false)
|
||||
close()
|
||||
}}
|
||||
>
|
||||
<TextField
|
||||
label="DNS Server"
|
||||
placeholder="1.2.3.4"
|
||||
name="ns"
|
||||
state={[ns, setNs]}
|
||||
className="mt-2 mb-8"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="block">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<Dialog.Text className="font-semibold">
|
||||
Restrict to domain
|
||||
</Dialog.Text>
|
||||
<Tooltip>
|
||||
<Tooltip.Button className={cn(
|
||||
'text-xs rounded-md px-1.5 py-0.5',
|
||||
'bg-ui-200 dark:bg-ui-800',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
|
||||
Split DNS
|
||||
</Tooltip.Button>
|
||||
<Tooltip.Body>
|
||||
Only clients that support split DNS
|
||||
(Tailscale v1.8 or later for most platforms)
|
||||
will use this nameserver. Older clients
|
||||
will ignore it.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Dialog.Text className="text-sm">
|
||||
This nameserver will only be used for some domains.
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
label="Split DNS"
|
||||
defaultSelected={split}
|
||||
onChange={() => { setSplit(!split) }}
|
||||
/>
|
||||
</div>
|
||||
{split
|
||||
? (
|
||||
<>
|
||||
<Dialog.Text className="font-semibold mt-8">
|
||||
Domain
|
||||
</Dialog.Text>
|
||||
<TextField
|
||||
label="Domain"
|
||||
placeholder="example.com"
|
||||
name="domain"
|
||||
state={[domain, setDomain]}
|
||||
className="my-2"
|
||||
/>
|
||||
<Dialog.Text className="text-sm">
|
||||
Only single-label or fully-qualified queries
|
||||
matching this suffix should use the nameserver.
|
||||
</Dialog.Text>
|
||||
</>
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Add
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useSubmit } from '@remix-run/react'
|
||||
import { Button } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Link from '~/components/Link'
|
||||
import TableList from '~/components/TableList'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import AddDNS from './dialogs/dns'
|
||||
|
||||
interface Props {
|
||||
records: { name: string, type: 'A', value: string }[]
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export default function DNS({ records, isDisabled }: Props) {
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
||||
<p className="text-gray-700 dark:text-gray-300">
|
||||
Headscale supports adding custom DNS records to your Tailnet.
|
||||
As of now, only
|
||||
{' '}
|
||||
<Code>A</Code>
|
||||
{' '}
|
||||
records are supported.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://headscale.net/dns-records/"
|
||||
name="Headscale DNS Records documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<TableList className="mb-8">
|
||||
{records.length === 0
|
||||
? (
|
||||
<TableList.Item>
|
||||
<p className="opacity-50 text-sm mx-auto">
|
||||
No DNS records found
|
||||
</p>
|
||||
</TableList.Item>
|
||||
)
|
||||
: records.map((record, index) => (
|
||||
<TableList.Item key={index}>
|
||||
<div className="flex gap-24">
|
||||
<div className="flex gap-2">
|
||||
<p className="font-mono text-sm font-bold">{record.type}</p>
|
||||
<p className="font-mono text-sm">{record.name}</p>
|
||||
</div>
|
||||
<p className="font-mono text-sm">{record.value}</p>
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'text-sm',
|
||||
'text-red-600 dark:text-red-400',
|
||||
'hover:text-red-700 dark:hover:text-red-300',
|
||||
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
onPress={() => {
|
||||
submit({
|
||||
'dns.extra_records': records
|
||||
.filter((_, i) => i !== index),
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))}
|
||||
</TableList>
|
||||
|
||||
{isDisabled
|
||||
? undefined
|
||||
: (
|
||||
<AddDNS records={records} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
/* 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.search_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({
|
||||
'dns.search_domains': localDomains.filter((_, index) => index !== id - 1)
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Spinner from '~/components/Spinner'
|
||||
|
||||
type Properties = {
|
||||
readonly isEnabled: boolean;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal({ isEnabled, disabled }: Properties) {
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={disabled}>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className='w-3 h-3'/>
|
||||
)}
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Devices will no longer be accessible via your tailnet domain.
|
||||
The search domain will also be disabled.
|
||||
</Dialog.Text>
|
||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
||||
<Dialog.Action
|
||||
variant='cancel'
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant='confirm'
|
||||
onPress={() => {
|
||||
fetcher.submit({
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'dns.magic_dns': !isEnabled
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
|
||||
close()
|
||||
}}
|
||||
>
|
||||
{isEnabled ? 'Disable' : 'Enable'} Magic DNS
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
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[]>
|
||||
isDisabled: boolean
|
||||
}
|
||||
|
||||
export default function Nameservers({ nameservers, 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]}
|
||||
name={key}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isDisabled
|
||||
? undefined
|
||||
: (
|
||||
<AddNameserver nameservers={nameservers} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ListProps {
|
||||
isGlobal: boolean
|
||||
isDisabled: boolean
|
||||
nameservers: string[]
|
||||
name: string
|
||||
}
|
||||
|
||||
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
|
||||
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>
|
||||
</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.nameservers.global': nameservers
|
||||
.filter((_, i) => i !== index),
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
} else {
|
||||
const key = `dns.nameservers.split."${name}"`
|
||||
const list = nameservers.filter((_, i) => i !== index)
|
||||
submit({
|
||||
[key]: list.length ? list : null,
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
})
|
||||
}
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))}
|
||||
</TableList>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/* eslint-disable @typescript-eslint/naming-convention */
|
||||
/* eslint-disable unicorn/no-keyword-prefix */
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
import { Input } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Spinner from '~/components/Spinner'
|
||||
import TextField from '~/components/TextField'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
type Properties = {
|
||||
readonly name: string;
|
||||
// eslint-disable-next-line react/boolean-prop-naming
|
||||
readonly disabled?: boolean;
|
||||
}
|
||||
|
||||
export default function Modal({ name, disabled }: Properties) {
|
||||
const [newName, setNewName] = useState(name)
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<div className='flex flex-col w-2/3'>
|
||||
<h1 className='text-2xl font-medium mb-4'>Tailnet Name</h1>
|
||||
<p className='text-gray-700 dark:text-gray-300'>
|
||||
This is the base domain name of your Tailnet.
|
||||
Devices are accessible at
|
||||
{' '}
|
||||
<Code>
|
||||
[device].{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.base_domain': newName
|
||||
}, {
|
||||
method: 'PATCH',
|
||||
encType: 'application/json'
|
||||
})
|
||||
|
||||
close()
|
||||
}}
|
||||
>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
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 DNS from './dns'
|
||||
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.magic_dns,
|
||||
baseDomain: config.dns.use_username_in_magic_dns
|
||||
? `[user].${config.dns.base_domain}`
|
||||
: config.dns.base_domain,
|
||||
nameservers: config.dns.nameservers.global,
|
||||
splitDns: config.dns.nameservers.split,
|
||||
searchDomains: config.dns.search_domains,
|
||||
extraRecords: config.dns.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?.onConfigChange) {
|
||||
await context.integration.onConfigChange(context.integration.context)
|
||||
}
|
||||
|
||||
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}
|
||||
isDisabled={!data.config.write}
|
||||
/>
|
||||
|
||||
<DNS
|
||||
records={data.extraRecords}
|
||||
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].
|
||||
{data.baseDomain}
|
||||
</Code>
|
||||
{' '}
|
||||
when Magic DNS is enabled.
|
||||
</p>
|
||||
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { Link, useLoaderData } from '@remix-run/react'
|
||||
|
||||
import Attribute from '~/components/Attribute'
|
||||
import Card from '~/components/Card'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { type Machine, Route, User } 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 { menuAction } from './_data.machines._index/action'
|
||||
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.magic_dns) {
|
||||
magic = config.dns.base_domain
|
||||
}
|
||||
}
|
||||
|
||||
const [machine, routes, users] = await Promise.all([
|
||||
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
|
||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
])
|
||||
|
||||
return {
|
||||
machine: machine.node,
|
||||
routes: routes.routes.filter(route => route.node.id === params.id),
|
||||
users: users.users,
|
||||
magic,
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
return menuAction(request)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { machine, magic, routes, users } = useLoaderData<typeof loader>()
|
||||
useLiveData({ interval: 1000 })
|
||||
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
? 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}
|
||||
users={users}
|
||||
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()}
|
||||
/>
|
||||
{magic
|
||||
? (
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Domain"
|
||||
value={`${machine.givenName}.${machine.user.name}.${magic}`}
|
||||
/>
|
||||
)
|
||||
: undefined}
|
||||
</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,135 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { ActionFunctionArgs, json } from '@remix-run/node'
|
||||
|
||||
import { del, post } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
|
||||
export async function menuAction(request: ActionFunctionArgs['request']) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ message: 'Unauthorized' }, {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.formData()
|
||||
if (!data.has('_method') || !data.has('id')) {
|
||||
return json({ message: 'No method or ID provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const id = String(data.get('id'))
|
||||
const method = String(data.get('_method'))
|
||||
|
||||
switch (method) {
|
||||
case 'delete': {
|
||||
await del(`v1/node/${id}`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Machine removed' })
|
||||
}
|
||||
|
||||
case 'expire': {
|
||||
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Machine expired' })
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
if (!data.has('name')) {
|
||||
return json({ message: 'No name provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const name = String(data.get('name'))
|
||||
|
||||
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Machine renamed' })
|
||||
}
|
||||
|
||||
case 'routes': {
|
||||
if (!data.has('route') || !data.has('enabled')) {
|
||||
return json({ message: 'No route or enabled provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const route = String(data.get('route'))
|
||||
const enabled = data.get('enabled') === 'true'
|
||||
const postfix = enabled ? 'enable' : 'disable'
|
||||
|
||||
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
|
||||
return json({ message: 'Route updated' })
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
if (!data.has('to')) {
|
||||
return json({ message: 'No destination provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const to = String(data.get('to'))
|
||||
|
||||
try {
|
||||
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
|
||||
return json({ message: `Moved node ${id} to ${to}` })
|
||||
} catch {
|
||||
return json({ message: `Failed to move node ${id} to ${to}` }, {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
case 'tags': {
|
||||
const tags = data.get('tags')?.toString()
|
||||
.split(',') ?? []
|
||||
|
||||
try {
|
||||
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
|
||||
tags,
|
||||
})
|
||||
|
||||
return json({ message: 'Tags updated' })
|
||||
} catch {
|
||||
return json({ message: 'Failed to update tags' }, {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
case 'register': {
|
||||
const key = data.get('mkey')?.toString()
|
||||
const user = data.get('user')?.toString()
|
||||
|
||||
if (!key) {
|
||||
return json({ message: 'No machine key provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return json({ message: 'No user provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
await post('v1/node/register', session.get('hsApiKey')!, {
|
||||
user, key,
|
||||
})
|
||||
|
||||
return json({ message: 'Machine registered' })
|
||||
} catch {
|
||||
return json({ message: 'Failed to register machine' }, {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return json({ message: 'Invalid method' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,119 @@
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Select from '~/components/Select'
|
||||
import { type Machine, User } from '~/types'
|
||||
|
||||
interface MoveProps {
|
||||
readonly machine: Machine
|
||||
readonly users: User[]
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
readonly magic?: string
|
||||
}
|
||||
|
||||
export default function Move({ machine, state, magic, users }: MoveProps) {
|
||||
const [owner, setOwner] = useState(machine.user.name)
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Change the owner of
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
The owner of the machine is the user associated with it.
|
||||
When MagicDNS is enabled, the username of the owner
|
||||
will control the hostname of the machine.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="move" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<Select
|
||||
label="Owner"
|
||||
name="to"
|
||||
placeholder="Select a user"
|
||||
state={[owner, setOwner]}
|
||||
>
|
||||
{users.map(user => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
{magic
|
||||
? (
|
||||
owner === machine.user.name
|
||||
? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine is accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
.
|
||||
{owner}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine will be accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
.
|
||||
{owner}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
{'. '}
|
||||
The hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
.
|
||||
{machine.user.name}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
{' '}
|
||||
will no longer point to this machine.
|
||||
</p>
|
||||
)
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Change owner
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { Dispatch, SetStateAction, useState } from 'react'
|
||||
import { PlusIcon, ServerIcon, KeyIcon } from '@primer/octicons-react'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import Select from '~/components/Select'
|
||||
import Menu from '~/components/Menu'
|
||||
import { Machine, User } from '~/types'
|
||||
|
||||
export interface NewProps {
|
||||
server: string
|
||||
users: User[]
|
||||
}
|
||||
|
||||
export default function New(data: NewProps) {
|
||||
const submit = useSubmit()
|
||||
const mkeyState = useState(false)
|
||||
const pkeyState = useState(false)
|
||||
const [mkey, setMkey] = useState('')
|
||||
const [user, setUser] = useState(data.users[0].id)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog>
|
||||
<Dialog.Panel control={mkeyState}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Register Machine Key
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className='mb-4'>
|
||||
The machine key is given when you run
|
||||
{' '}
|
||||
<Code>
|
||||
tailscale up --login-server=
|
||||
</Code>
|
||||
<Code>
|
||||
{data.server}
|
||||
</Code>
|
||||
{' '}
|
||||
on your device.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="register" />
|
||||
<input type="hidden" name="id" value="_" />
|
||||
<TextField
|
||||
label='Machine Key'
|
||||
placeholder='nodekey:ff.....'
|
||||
name="mkey"
|
||||
state={[mkey, setMkey]}
|
||||
className='my-2 font-mono'
|
||||
/>
|
||||
<Select
|
||||
label="Owner"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
state={[user, setUser]}
|
||||
>
|
||||
{data.users.map(user => (
|
||||
<Select.Item key={user.id} id={user.name}>
|
||||
{user.name}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<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}
|
||||
>
|
||||
Register
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
<Menu>
|
||||
<Menu.Button
|
||||
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',
|
||||
)}
|
||||
>
|
||||
Add Device
|
||||
</Menu.Button>
|
||||
<Menu.Items>
|
||||
<Menu.ItemButton control={mkeyState}>
|
||||
<ServerIcon className='w-4 h-4 mr-2'/>
|
||||
Register Machine Key
|
||||
</Menu.ItemButton>
|
||||
<Menu.ItemButton control={pkeyState} isDisabled>
|
||||
<KeyIcon className='w-4 h-4 mr-2'/>
|
||||
Generate Pre-auth Key
|
||||
</Menu.ItemButton>
|
||||
</Menu.Items>
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
import TextField from '~/components/TextField'
|
||||
import { type Machine } from '~/types'
|
||||
|
||||
interface RenameProps {
|
||||
readonly machine: Machine
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
readonly magic?: string
|
||||
}
|
||||
|
||||
export default function Rename({ machine, state, magic }: RenameProps) {
|
||||
const [name, setName] = useState(machine.givenName)
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Edit machine name for
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This name is shown in the admin panel, in Tailscale clients,
|
||||
and used when generating MagicDNS names.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="rename" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<TextField
|
||||
label="Machine name"
|
||||
placeholder="Machine name"
|
||||
name="name"
|
||||
state={[name, setName]}
|
||||
className="my-2"
|
||||
/>
|
||||
{magic
|
||||
? (
|
||||
name.length > 0 && name !== machine.givenName
|
||||
? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine will be accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
||||
</Code>
|
||||
{'. '}
|
||||
The hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
</Code>
|
||||
{' '}
|
||||
will no longer point to this machine.
|
||||
</p>
|
||||
)
|
||||
: (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||
This machine is accessible by the hostname
|
||||
{' '}
|
||||
<Code className="text-sm">
|
||||
{machine.givenName}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
)
|
||||
: undefined}
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { useFetcher } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction } from 'react'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Switch from '~/components/Switch'
|
||||
import { type Machine, type Route } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface RoutesProps {
|
||||
readonly machine: Machine
|
||||
readonly routes: Route[]
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
}
|
||||
|
||||
// TODO: Support deleting routes
|
||||
export default function Routes({ machine, routes, state }: RoutesProps) {
|
||||
const fetcher = useFetcher()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Edit route settings of
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Connect to devices you can't install Tailscale on
|
||||
by advertising IP ranges as subnet routes.
|
||||
</Dialog.Text>
|
||||
<div className={cn(
|
||||
'rounded-lg overflow-y-auto my-2',
|
||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||
'border border-zinc-200 dark:border-zinc-700',
|
||||
)}
|
||||
>
|
||||
{routes.length === 0
|
||||
? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-center',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
No routes are advertised on this machine.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: undefined}
|
||||
{routes.map(route => (
|
||||
<div
|
||||
key={route.node.id}
|
||||
className={cn(
|
||||
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-between',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
{route.prefix}
|
||||
</p>
|
||||
<Switch
|
||||
defaultSelected={route.enabled}
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData()
|
||||
form.set('id', machine.id)
|
||||
form.set('_method', 'routes')
|
||||
form.set('route', route.id)
|
||||
|
||||
form.set('enabled', String(checked))
|
||||
fetcher.submit(form, {
|
||||
method: 'POST',
|
||||
})
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
isDisabled={fetcher.state === 'submitting'}
|
||||
onPress={close}
|
||||
>
|
||||
Close
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { PlusIcon, XIcon } from '@primer/octicons-react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { type Dispatch, type SetStateAction, useState } from 'react'
|
||||
import { Button, Input } from 'react-aria-components'
|
||||
|
||||
import Dialog from '~/components/Dialog'
|
||||
import Link from '~/components/Link'
|
||||
import { type Machine } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
interface TagsProps {
|
||||
readonly machine: Machine
|
||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||
}
|
||||
|
||||
export default function Tags({ machine, state }: TagsProps) {
|
||||
const [tags, setTags] = useState(machine.forcedTags)
|
||||
const [tag, setTag] = useState('')
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Panel control={state}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Edit ACL tags for
|
||||
{' '}
|
||||
{machine.givenName}
|
||||
</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
ACL tags can be used to reference machines in your ACL policies.
|
||||
See the
|
||||
{' '}
|
||||
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1068/acl-tags"
|
||||
name="Tailscale documentation"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
{' '}
|
||||
for more information.
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(e) => {
|
||||
submit(e.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="tags" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||
<div
|
||||
className={cn(
|
||||
'border border-ui-300 rounded-lg overflow-visible',
|
||||
'dark:border-ui-700 dark:text-ui-300 mt-4',
|
||||
)}
|
||||
>
|
||||
<div className="divide-y divide-ui-200 dark:divide-ui-600">
|
||||
{tags.length === 0
|
||||
? (
|
||||
<div
|
||||
className={cn(
|
||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
||||
'items-center justify-center rounded-t-lg',
|
||||
'text-ui-600 dark:text-ui-300',
|
||||
)}
|
||||
>
|
||||
<p>
|
||||
No tags are set on this machine.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
: tags.map(item => (
|
||||
<div
|
||||
key={item}
|
||||
id={item}
|
||||
className={cn(
|
||||
'px-2.5 py-1.5 flex',
|
||||
'items-center justify-between',
|
||||
'font-mono text-sm',
|
||||
)}
|
||||
>
|
||||
{item}
|
||||
<Button
|
||||
className="rounded-full p-0 w-6 h-6"
|
||||
onPress={() => {
|
||||
setTags(tags.filter(tag => tag !== item))
|
||||
}}
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'flex px-2.5 py-1.5 w-full',
|
||||
'border-t border-ui-300 dark:border-ui-700',
|
||||
'rounded-b-lg justify-between items-center',
|
||||
'dark:bg-ui-800 dark:text-ui-300',
|
||||
'focus-within:ring-2 focus-within:ring-blue-600',
|
||||
tag.length > 0 && !tag.startsWith('tag:')
|
||||
&& 'outline outline-red-500',
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
placeholder="tag:example"
|
||||
className={cn(
|
||||
'bg-transparent w-full',
|
||||
'border-none focus:ring-0',
|
||||
'focus:outline-none font-mono text-sm',
|
||||
'dark:bg-transparent dark:text-ui-300',
|
||||
)}
|
||||
value={tag}
|
||||
onChange={(e) => {
|
||||
setTag(e.currentTarget.value)
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
className={cn(
|
||||
'rounded-lg p-0 h-6 w-6',
|
||||
!tag.startsWith('tag:')
|
||||
&& 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={
|
||||
tag.length === 0
|
||||
|| !tag.startsWith('tag:')
|
||||
|| tags.includes(tag)
|
||||
}
|
||||
onPress={() => {
|
||||
setTags([...tags, tag])
|
||||
setTag('')
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Save
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
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, User } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import MenuOptions from './menu'
|
||||
|
||||
interface Props {
|
||||
readonly machine: Machine
|
||||
readonly routes: Route[]
|
||||
readonly users: User[]
|
||||
readonly magic?: string
|
||||
}
|
||||
|
||||
export default function MachineRow({ machine, routes, magic, users }: Props) {
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now()
|
||||
|
||||
const tags = [
|
||||
...machine.forcedTags,
|
||||
...machine.validTags,
|
||||
]
|
||||
|
||||
if (expired) {
|
||||
tags.unshift('Expired')
|
||||
}
|
||||
|
||||
let prefix = magic?.startsWith('[user]')
|
||||
? magic.replace('[user]', machine.user.name)
|
||||
: magic
|
||||
|
||||
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}.${prefix}`
|
||||
await navigator.clipboard.writeText(ip)
|
||||
toast('Copied hostname to clipboard')
|
||||
}}
|
||||
>
|
||||
{machine.givenName}
|
||||
.
|
||||
{prefix}
|
||||
<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}
|
||||
users={users}
|
||||
magic={magic}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { KebabHorizontalIcon } from '@primer/octicons-react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import MenuComponent from '~/components/Menu'
|
||||
import { Machine, Route, User } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
|
||||
import Delete from './dialogs/delete'
|
||||
import Expire from './dialogs/expire'
|
||||
import Move from './dialogs/move'
|
||||
import Rename from './dialogs/rename'
|
||||
import Routes from './dialogs/routes'
|
||||
import Tags from './dialogs/tags'
|
||||
|
||||
interface MenuProps {
|
||||
machine: Machine
|
||||
routes: Route[]
|
||||
users: User[]
|
||||
magic?: string
|
||||
}
|
||||
|
||||
export default function Menu({ machine, routes, magic, users }: MenuProps) {
|
||||
const renameState = useState(false)
|
||||
const expireState = useState(false)
|
||||
const removeState = useState(false)
|
||||
const routesState = useState(false)
|
||||
const moveState = useState(false)
|
||||
const tagsState = useState(false)
|
||||
|
||||
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||
? 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}
|
||||
/>
|
||||
<Tags
|
||||
machine={machine}
|
||||
state={tagsState}
|
||||
/>
|
||||
<Move
|
||||
machine={machine}
|
||||
state={moveState}
|
||||
users={users}
|
||||
magic={magic}
|
||||
/>
|
||||
|
||||
<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.ItemButton control={tagsState}>
|
||||
Edit ACL tags
|
||||
</MenuComponent.ItemButton>
|
||||
<MenuComponent.ItemButton control={moveState}>
|
||||
Change owner
|
||||
</MenuComponent.ItemButton>
|
||||
{expired
|
||||
? undefined
|
||||
: (
|
||||
<MenuComponent.ItemButton control={expireState}>
|
||||
Expire
|
||||
</MenuComponent.ItemButton>
|
||||
)}
|
||||
<MenuComponent.ItemButton
|
||||
className="text-red-500 dark:text-red-400"
|
||||
control={removeState}
|
||||
>
|
||||
Remove
|
||||
</MenuComponent.ItemButton>
|
||||
</MenuComponent.Items>
|
||||
</MenuComponent>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { InfoIcon } from '@primer/octicons-react'
|
||||
import { type ActionFunctionArgs, type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useLoaderData } from '@remix-run/react'
|
||||
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
|
||||
|
||||
import Code from '~/components/Code'
|
||||
import { type Machine, type Route, User } 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 { menuAction } from './action'
|
||||
import MachineRow from './machine'
|
||||
import NewMachine from './dialogs/new'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
const [machines, routes, users] = await Promise.all([
|
||||
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
])
|
||||
|
||||
const context = await loadContext()
|
||||
let magic: string | undefined
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain
|
||||
}
|
||||
|
||||
if (config.dns.use_username_in_magic_dns) {
|
||||
magic = `[user].${magic}`
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: machines.nodes,
|
||||
routes: routes.routes,
|
||||
users: users.users,
|
||||
magic,
|
||||
server: context.headscaleUrl,
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
return menuAction(request)
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
useLiveData({ interval: 3000 })
|
||||
const data = useLoaderData<typeof loader>()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-medium mb-4">Machines</h1>
|
||||
<NewMachine server={data.server} users={data.users} />
|
||||
</div>
|
||||
<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].
|
||||
{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)}
|
||||
users={data.users}
|
||||
magic={data.magic}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { IssueDraftIcon } from '@primer/octicons-react'
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center my-8'>
|
||||
<IssueDraftIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
|
||||
<p className='text-lg mt-8'>
|
||||
The settings page is currently unavailable.
|
||||
It will be available in a future release.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
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 {
|
||||
config: context.config,
|
||||
user: session.get('user'),
|
||||
}
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const nav = useNavigation()
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProgressBar
|
||||
aria-label="Loading..."
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'fixed top-0 left-0 z-50 w-1/2 h-1',
|
||||
'bg-blue-500 dark:bg-blue-400 opacity-0',
|
||||
nav.state === 'loading' && 'animate-loading opacity-100',
|
||||
)}
|
||||
/>
|
||||
</ProgressBar>
|
||||
<Header data={data} />
|
||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||
<Outlet />
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return (
|
||||
<>
|
||||
<Header />
|
||||
<ErrorPopup type="embedded" />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
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].
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
.
|
||||
</>
|
||||
)
|
||||
: undefined}
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
submit(event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="create" />
|
||||
<TextField
|
||||
label="Username"
|
||||
placeholder="my-new-user"
|
||||
name="username"
|
||||
state={[username, setUsername]}
|
||||
className="my-2"
|
||||
/>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Create
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react'
|
||||
|
||||
import Card from '~/components/Card'
|
||||
import Link from '~/components/Link'
|
||||
|
||||
import Add from './add'
|
||||
|
||||
interface Props {
|
||||
readonly magic: string | undefined
|
||||
}
|
||||
|
||||
export default function Auth({ magic }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
|
||||
<HomeIcon className="w-5 h-5 mb-2" />
|
||||
<h2 className="font-medium mb-1">
|
||||
Basic Authentication
|
||||
</h2>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
Users are not managed externally.
|
||||
Using OpenID Connect can create a better
|
||||
experience when using Headscale.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://headscale.net/oidc"
|
||||
name="Headscale OIDC Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
|
||||
<PasskeyFillIcon className="w-5 h-5 mb-2" />
|
||||
<h2 className="font-medium mb-1">
|
||||
User Management
|
||||
</h2>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
You can add, remove, and rename users here.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Add magic={magic} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react'
|
||||
|
||||
import Card from '~/components/Card'
|
||||
import Link from '~/components/Link'
|
||||
import { HeadplaneContext } from '~/utils/config/headplane'
|
||||
|
||||
import Add from './add'
|
||||
|
||||
interface Props {
|
||||
readonly oidc: NonNullable<HeadplaneContext['oidc']>
|
||||
readonly magic: string | undefined
|
||||
}
|
||||
|
||||
export default function Oidc({ oidc, magic }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
|
||||
<OrganizationIcon className="w-5 h-5 mb-2" />
|
||||
<h2 className="font-medium mb-1">
|
||||
OpenID Connect
|
||||
</h2>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
Users are managed through your
|
||||
{' '}
|
||||
<Link to={oidc.issuer} name="OIDC Provider">
|
||||
OpenID Connect provider
|
||||
</Link>
|
||||
{'. '}
|
||||
Groups and user information do not automatically sync.
|
||||
{' '}
|
||||
<Link
|
||||
to="https://headscale.net/oidc"
|
||||
name="Headscale OIDC Documentation"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
|
||||
<PasskeyFillIcon className="w-5 h-5 mb-2" />
|
||||
<h2 className="font-medium mb-1">
|
||||
User Management
|
||||
</h2>
|
||||
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||
You can still add users manually, however it is recommended
|
||||
that you manage users through your OIDC provider.
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Add magic={magic} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { XIcon } from '@primer/octicons-react'
|
||||
import { Form, useSubmit } from '@remix-run/react'
|
||||
import { useState } from 'react'
|
||||
|
||||
import Button from '~/components/Button'
|
||||
import Code from '~/components/Code'
|
||||
import Dialog from '~/components/Dialog'
|
||||
|
||||
interface Props {
|
||||
username: string
|
||||
magic?: string
|
||||
}
|
||||
|
||||
export default function Remove({ username, magic }: Props) {
|
||||
const submit = useSubmit()
|
||||
const dialogState = useState(false)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
variant="light"
|
||||
control={dialogState}
|
||||
className="rounded-full p-0 w-8 h-8"
|
||||
>
|
||||
<XIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Dialog control={dialogState}>
|
||||
<Dialog.Panel control={dialogState}>
|
||||
{close => (
|
||||
<>
|
||||
<Dialog.Title>
|
||||
Delete
|
||||
{' '}
|
||||
{username}
|
||||
?
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className="mb-8">
|
||||
Are you sure you want to delete
|
||||
{' '}
|
||||
{username}
|
||||
?
|
||||
{' '}
|
||||
A deleted user cannot be recovered.
|
||||
{magic
|
||||
? (
|
||||
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
|
||||
{' '}
|
||||
Since Magic DNS is enabled, machines
|
||||
currently accessible via
|
||||
{' '}
|
||||
<Code>
|
||||
[machine].
|
||||
{username}
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
{' '}
|
||||
will become orphaned and inaccessible.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
submit(event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="delete" />
|
||||
<input type="hidden" name="username" value={username} />
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Delete
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
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].
|
||||
.
|
||||
{magic}
|
||||
</Code>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
: undefined}
|
||||
</Dialog.Text>
|
||||
<Form
|
||||
method="POST"
|
||||
onSubmit={(event) => {
|
||||
submit(event.currentTarget)
|
||||
}}
|
||||
>
|
||||
<input type="hidden" name="_method" value="rename" />
|
||||
<input type="hidden" name="old" value={username} />
|
||||
<TextField
|
||||
label="Username"
|
||||
placeholder="my-new-name"
|
||||
name="new"
|
||||
state={[newName, setNewName]}
|
||||
className="my-2"
|
||||
/>
|
||||
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||
<Dialog.Action
|
||||
variant="cancel"
|
||||
onPress={close}
|
||||
>
|
||||
Cancel
|
||||
</Dialog.Action>
|
||||
<Dialog.Action
|
||||
variant="confirm"
|
||||
onPress={close}
|
||||
>
|
||||
Rename
|
||||
</Dialog.Action>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||
import { type DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
|
||||
import { PersonIcon } from '@primer/octicons-react'
|
||||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
|
||||
import { useActionData, useLoaderData, useSubmit } from '@remix-run/react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { ClientOnly } from 'remix-utils/client-only'
|
||||
|
||||
import Attribute from '~/components/Attribute'
|
||||
import Card from '~/components/Card'
|
||||
import StatusCircle from '~/components/StatusCircle'
|
||||
import { toast } from '~/components/Toaster'
|
||||
import { type Machine, type User } from '~/types'
|
||||
import { cn } from '~/utils/cn'
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { loadConfig } from '~/utils/config/headscale'
|
||||
import { del, post, pull } from '~/utils/headscale'
|
||||
import { getSession } from '~/utils/sessions'
|
||||
import { useLiveData } from '~/utils/useLiveData'
|
||||
|
||||
import Auth from './auth'
|
||||
import Oidc from './oidc'
|
||||
import Remove from './remove'
|
||||
import Rename from './rename'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
|
||||
const [machines, apiUsers] = await Promise.all([
|
||||
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
])
|
||||
|
||||
const users = apiUsers.users.map(user => ({
|
||||
...user,
|
||||
machines: machines.nodes.filter(machine => machine.user.id === user.id),
|
||||
}))
|
||||
|
||||
const context = await loadContext()
|
||||
let magic: string | undefined
|
||||
|
||||
if (context.config.read) {
|
||||
const config = await loadConfig()
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oidc: context.oidc,
|
||||
magic,
|
||||
users,
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
if (!session.has('hsApiKey')) {
|
||||
return json({ message: 'Unauthorized' }, {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
|
||||
const data = await request.formData()
|
||||
if (!data.has('_method')) {
|
||||
return json({ message: 'No method provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const method = String(data.get('_method'))
|
||||
|
||||
switch (method) {
|
||||
case 'create': {
|
||||
if (!data.has('username')) {
|
||||
return json({ message: 'No name provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const username = String(data.get('username'))
|
||||
await post('v1/user', session.get('hsApiKey')!, {
|
||||
name: username,
|
||||
})
|
||||
|
||||
return json({ message: `User ${username} created` })
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
if (!data.has('username')) {
|
||||
return json({ message: 'No name provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const username = String(data.get('username'))
|
||||
await del(`v1/user/${username}`, session.get('hsApiKey')!)
|
||||
return json({ message: `User ${username} deleted` })
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
if (!data.has('old') || !data.has('new')) {
|
||||
return json({ message: 'No old or new name provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const old = String(data.get('old'))
|
||||
const newName = String(data.get('new'))
|
||||
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!)
|
||||
return json({ message: `User ${old} renamed to ${newName}` })
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
if (!data.has('id') || !data.has('to') || !data.has('name')) {
|
||||
return json({ message: 'No ID or destination provided' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
|
||||
const id = String(data.get('id'))
|
||||
const to = String(data.get('to'))
|
||||
const name = String(data.get('name'))
|
||||
|
||||
try {
|
||||
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
|
||||
return json({ message: `Moved ${name} to ${to}` })
|
||||
} catch {
|
||||
return json({ message: `Failed to move ${name} to ${to}` }, {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return json({ message: 'Invalid method' }, {
|
||||
status: 400,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const [users, setUsers] = useState(data.users)
|
||||
const actionData = useActionData<typeof action>()
|
||||
useLiveData({ interval: 3000 })
|
||||
|
||||
useEffect(() => {
|
||||
if (!actionData) {
|
||||
return
|
||||
}
|
||||
|
||||
toast(actionData.message)
|
||||
if (actionData.message.startsWith('Failed')) {
|
||||
setUsers(data.users)
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [actionData])
|
||||
|
||||
useEffect(() => {
|
||||
setUsers(data.users)
|
||||
}, [data.users])
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-medium mb-1.5">
|
||||
Users
|
||||
</h1>
|
||||
<p className="mb-8 text-md">
|
||||
Manage the users in your network and their permissions.
|
||||
Tip: You can drag machines between users to change ownership.
|
||||
</p>
|
||||
{data.oidc
|
||||
? (
|
||||
<Oidc
|
||||
oidc={data.oidc}
|
||||
magic={data.magic}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<Auth magic={data.magic} />
|
||||
)}
|
||||
<ClientOnly fallback={
|
||||
<Users users={users} />
|
||||
}
|
||||
>
|
||||
{() => (
|
||||
<InteractiveUsers
|
||||
users={users}
|
||||
setUsers={setUsers}
|
||||
magic={data.magic}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type UserMachine = User & { machines: Machine[] }
|
||||
|
||||
interface UserProps {
|
||||
users: UserMachine[]
|
||||
setUsers?: (users: UserMachine[]) => void
|
||||
magic?: string
|
||||
}
|
||||
|
||||
function Users({ users, magic }: UserProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||
{users.map(user => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
magic={magic}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InteractiveUsers({ users, setUsers, magic }: UserProps) {
|
||||
const submit = useSubmit()
|
||||
|
||||
return (
|
||||
<DndContext onDragEnd={(event) => {
|
||||
const { over, active } = event
|
||||
if (!over) {
|
||||
return
|
||||
}
|
||||
|
||||
// Update the UI optimistically
|
||||
const newUsers = new Array<UserMachine>()
|
||||
const reference = active.data as DataRef<Machine>
|
||||
if (!reference.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Ignore if the user is unchanged
|
||||
if (reference.current.user.name === over.id) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
newUsers.push({
|
||||
...user,
|
||||
machines: over.id === user.name
|
||||
? [...user.machines, reference.current]
|
||||
: user.machines.filter(m => m.id !== active.id),
|
||||
})
|
||||
}
|
||||
|
||||
setUsers?.(newUsers)
|
||||
const data = new FormData()
|
||||
data.append('_method', 'move')
|
||||
data.append('id', active.id.toString())
|
||||
data.append('to', over.id.toString())
|
||||
data.append('name', reference.current.givenName)
|
||||
|
||||
submit(data, {
|
||||
method: 'POST',
|
||||
})
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||
{users.map(user => (
|
||||
<UserCard
|
||||
key={user.id}
|
||||
user={user}
|
||||
magic={magic}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
)
|
||||
}
|
||||
|
||||
function MachineChip({ machine }: { readonly machine: Machine }) {
|
||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||
id: machine.id,
|
||||
data: machine,
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex items-center w-full gap-2 py-1',
|
||||
'hover:bg-ui-100 dark:hover:bg-ui-800 rounded-lg',
|
||||
)}
|
||||
style={{
|
||||
transform: transform
|
||||
? `translate3d(${transform.x.toString()}px, ${transform.y.toString()}px, 0)`
|
||||
: undefined,
|
||||
}}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<StatusCircle isOnline={machine.online} className="w-4 h-4 px-1 w-fit" />
|
||||
<Attribute
|
||||
name={machine.givenName}
|
||||
value={machine.ipAddresses[0]}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface CardProps {
|
||||
user: UserMachine
|
||||
magic?: string
|
||||
}
|
||||
|
||||
function UserCard({ user, magic }: CardProps) {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: user.name,
|
||||
})
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef}>
|
||||
<Card
|
||||
variant="flat"
|
||||
className={cn(
|
||||
'max-w-full w-full overflow-visible h-full',
|
||||
isOver ? 'bg-ui-100 dark:bg-ui-800' : '',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<PersonIcon className="w-6 h-6" />
|
||||
<span className="text-lg font-mono">
|
||||
{user.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Rename username={user.name} magic={magic} />
|
||||
{user.machines.length === 0
|
||||
? (
|
||||
<Remove username={user.name} magic={magic} />
|
||||
)
|
||||
: undefined}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{user.machines.map(machine => (
|
||||
<MachineChip key={machine.id} machine={machine} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from '@remix-run/node'
|
||||
|
||||
export function loader() {
|
||||
return redirect('/machines')
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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 {
|
||||
return json({
|
||||
error: 'Invalid API key',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>()
|
||||
const actionData = useActionData<typeof action>()
|
||||
const showOr = useMemo(() => data.oidc && data.apiKey, [data])
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="max-w-sm m-4 sm:m-0 rounded-2xl">
|
||||
<Card.Title>
|
||||
Welcome to Headplane
|
||||
</Card.Title>
|
||||
{data.apiKey
|
||||
? (
|
||||
<Form method="post">
|
||||
<Card.Text className="mb-8 text-sm">
|
||||
Enter an API key to authenticate with Headplane. You can generate
|
||||
one by running
|
||||
{' '}
|
||||
<Code>
|
||||
headscale apikeys create
|
||||
</Code>
|
||||
{' '}
|
||||
in your terminal.
|
||||
</Card.Text>
|
||||
|
||||
{actionData?.error
|
||||
? (
|
||||
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
|
||||
)
|
||||
: undefined}
|
||||
<TextField
|
||||
isRequired
|
||||
label="API Key"
|
||||
name="api-key"
|
||||
placeholder="API Key"
|
||||
/>
|
||||
<Button
|
||||
className="w-full mt-2.5"
|
||||
variant="heavy"
|
||||
type="submit"
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
</Form>
|
||||
)
|
||||
: undefined}
|
||||
{showOr
|
||||
? (
|
||||
<div className="flex items-center gap-x-1.5 py-1">
|
||||
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
|
||||
<span className="text-gray-500 text-sm">or</span>
|
||||
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
|
||||
</div>
|
||||
)
|
||||
: undefined}
|
||||
{data.oidc
|
||||
? (
|
||||
<Form method="POST">
|
||||
<input type="hidden" name="oidc-start" value="true" />
|
||||
<Button
|
||||
className="w-full"
|
||||
variant="heavy"
|
||||
type="submit"
|
||||
>
|
||||
Login with SSO
|
||||
</Button>
|
||||
</Form>
|
||||
)
|
||||
: undefined}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { type ActionFunctionArgs, redirect } from '@remix-run/node'
|
||||
|
||||
import { destroySession, getSession } from '~/utils/sessions'
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'))
|
||||
const returnTo = new URL(request.url).pathname
|
||||
|
||||
return redirect(`/login?returnTo=${returnTo}`, {
|
||||
headers: {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
'Set-Cookie': await destroySession(session)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { type LoaderFunctionArgs } from '@remix-run/node'
|
||||
|
||||
import { loadContext } from '~/utils/config/headplane'
|
||||
import { finishOidc } from '~/utils/oidc'
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const context = await loadContext()
|
||||
if (!context.oidc) {
|
||||
throw new Error('An invalid OIDC configuration was provided')
|
||||
}
|
||||
|
||||
return finishOidc(context.oidc, request)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@supports (scrollbar-gutter: stable) {
|
||||
html {
|
||||
scrollbar-gutter: stable
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export type Key = {
|
||||
id: string;
|
||||
prefix: string;
|
||||
expiration: string;
|
||||
createdAt: Date;
|
||||
lastSeen: Date;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { User } from './User'
|
||||
|
||||
export interface Machine {
|
||||
id: string
|
||||
machineKey: string
|
||||
nodeKey: string
|
||||
discoKey: string
|
||||
ipAddresses: string[]
|
||||
name: string
|
||||
|
||||
user: User
|
||||
lastSeen: string
|
||||
expiry: string
|
||||
|
||||
preAuthKey?: unknown // TODO
|
||||
|
||||
createdAt: string
|
||||
registerMethod: 'REGISTER_METHOD_UNSPECIFIED'
|
||||
| 'REGISTER_METHOD_AUTH_KEY'
|
||||
| 'REGISTER_METHOD_CLI'
|
||||
| 'REGISTER_METHOD_OIDC'
|
||||
|
||||
forcedTags: string[]
|
||||
invalidTags: string[]
|
||||
validTags: string[]
|
||||
givenName: string
|
||||
online: boolean
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { Machine } from './Machine'
|
||||
|
||||
export interface Route {
|
||||
id: string
|
||||
node: Machine
|
||||
prefix: string
|
||||
advertised: boolean
|
||||
enabled: boolean
|
||||
isPrimary: boolean
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
deletedAt: string
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface User {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './Key'
|
||||
export * from './Machine'
|
||||
export * from './Route'
|
||||
export * from './User'
|
||||
@@ -0,0 +1,4 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs))
|
||||
@@ -0,0 +1,203 @@
|
||||
// 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 { IntegrationFactory, loadIntegration } from '~/integration'
|
||||
import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale'
|
||||
import log from '~/utils/log'
|
||||
|
||||
export interface HeadplaneContext {
|
||||
debug: boolean
|
||||
headscaleUrl: string
|
||||
cookieSecret: string
|
||||
integration: IntegrationFactory | undefined
|
||||
|
||||
config: {
|
||||
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 debug = process.env.DEBUG === 'true'
|
||||
if (debug) {
|
||||
log.info('CTXT', 'Debug mode is enabled! Logs will spam a lot.')
|
||||
log.info('CTXT', 'Please disable debug mode in production.')
|
||||
}
|
||||
|
||||
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 = {
|
||||
debug,
|
||||
headscaleUrl,
|
||||
cookieSecret,
|
||||
integration: await loadIntegration(),
|
||||
config: contextData,
|
||||
oidc: await checkOidc(config),
|
||||
}
|
||||
|
||||
log.info('CTXT', 'Starting Headplane with Context')
|
||||
log.info('CTXT', 'HEADSCALE_URL: %s', headscaleUrl)
|
||||
log.info('CTXT', 'Integration: %s', context.integration?.name ?? 'None')
|
||||
log.info('CTXT', 'Config: %s', contextData.read
|
||||
? `Found ${contextData.write ? '' : '(Read Only)'}`
|
||||
: 'Unavailable',
|
||||
)
|
||||
|
||||
log.info('CTXT', 'OIDC: %s', context.oidc ? 'Configured' : 'Unavailable')
|
||||
return context
|
||||
}
|
||||
|
||||
async function checkConfig(path: string) {
|
||||
log.debug('CTXT', 'Checking config at %s', path)
|
||||
|
||||
let config: HeadscaleConfig | undefined
|
||||
try {
|
||||
config = await loadConfig(path)
|
||||
} catch {
|
||||
log.debug('CTXT', 'Config at %s failed to load', path)
|
||||
return {
|
||||
config: undefined,
|
||||
contextData: {
|
||||
read: false,
|
||||
write: false,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let write = false
|
||||
try {
|
||||
log.debug('CTXT', 'Checking write access to %s', path)
|
||||
await access(path, constants.W_OK)
|
||||
write = true
|
||||
} catch {
|
||||
log.debug('CTXT', 'No write access to %s', path)
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
contextData: {
|
||||
read: true,
|
||||
write,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOidc(config?: HeadscaleConfig) {
|
||||
log.debug('CTXT', 'Checking OIDC configuration')
|
||||
|
||||
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true'
|
||||
log.debug('CTXT', 'API Key Login Enabled: %s', !disableKeyLogin)
|
||||
|
||||
log.debug('CTXT', 'Checking ROOT_API_KEY and falling back to API_KEY')
|
||||
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
|
||||
|
||||
log.debug('CTXT', 'Checking OIDC environment variables')
|
||||
log.debug('CTXT', 'Issuer: %s', issuer)
|
||||
log.debug('CTXT', 'Client: %s', client)
|
||||
|
||||
if (
|
||||
(issuer ?? client ?? secret)
|
||||
&& !(issuer && client && secret)
|
||||
&& !config
|
||||
) {
|
||||
throw new Error('OIDC environment variables are incomplete')
|
||||
}
|
||||
|
||||
if (issuer && client && secret) {
|
||||
return {
|
||||
issuer,
|
||||
client,
|
||||
secret,
|
||||
rootKey,
|
||||
disableKeyLogin,
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
log.debug('CTXT', 'Trying to read OIDC client secret from %s', 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 {
|
||||
log.error('CTXT', 'Failed to read OIDC client secret from %s', config.oidc.client_secret_path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
(issuer ?? client ?? secret)
|
||||
&& !(issuer && client && secret)
|
||||
) {
|
||||
throw new Error('OIDC configuration is incomplete')
|
||||
}
|
||||
|
||||
if (!issuer || !client || !secret) {
|
||||
return
|
||||
}
|
||||
|
||||
return {
|
||||
issuer,
|
||||
client,
|
||||
secret,
|
||||
rootKey,
|
||||
disableKeyLogin,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// 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'
|
||||
|
||||
import log from '~/utils/log'
|
||||
|
||||
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),
|
||||
|
||||
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'),
|
||||
|
||||
policy: z.object({
|
||||
mode: z.enum(['file', 'database']).default('file'),
|
||||
path: z.string().optional(),
|
||||
}).optional(),
|
||||
|
||||
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: z.object({
|
||||
magic_dns: goBool.default(true),
|
||||
base_domain: z.string().default('headscale.net'),
|
||||
nameservers: z.object({
|
||||
global: z.array(z.string()).default([]),
|
||||
split: z.record(z.array(z.string())).default({}),
|
||||
}).default({ global: [], split: {} }),
|
||||
search_domains: z.array(z.string()).default([]),
|
||||
extra_records: z.array(z.object({
|
||||
name: z.string(),
|
||||
type: z.literal('A'),
|
||||
value: z.string(),
|
||||
})).default([]),
|
||||
use_username_in_magic_dns: goBool.default(false),
|
||||
}),
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
log.debug('CFGX', 'Loading Headscale configuration from %s', path)
|
||||
const data = await readFile(path, 'utf8')
|
||||
configYaml = parseDocument(data)
|
||||
|
||||
if (process.env.HEADSCALE_CONFIG_UNSTRICT === 'true') {
|
||||
log.debug('CFGX', 'Loaded Headscale configuration in non-strict mode')
|
||||
const loaded = configYaml.toJSON() as Record<string, unknown>
|
||||
config = {
|
||||
...loaded,
|
||||
tls_letsencrypt_cache_dir: loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache',
|
||||
tls_letsencrypt_challenge_type: loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01',
|
||||
grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443',
|
||||
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
|
||||
randomize_client_port: loaded.randomize_client_port ?? false,
|
||||
unix_socket: loaded.unix_socket ?? '/var/run/headscale/headscale.sock',
|
||||
unix_socket_permission: loaded.unix_socket_permission ?? '0o770',
|
||||
tuning: loaded.tuning ?? {
|
||||
batch_change_delay: '800ms',
|
||||
node_mapsession_buffered_chan_size: 30,
|
||||
},
|
||||
|
||||
log: loaded.log ?? {
|
||||
level: 'info',
|
||||
format: 'text',
|
||||
},
|
||||
|
||||
logtail: loaded.logtail ?? {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
cli: loaded.cli ?? {
|
||||
timeout: '10s',
|
||||
insecure: false,
|
||||
},
|
||||
|
||||
prefixes: loaded.prefixes ?? {
|
||||
allocation: 'sequential',
|
||||
v4: '',
|
||||
v6: '',
|
||||
},
|
||||
|
||||
dns: loaded.dns ?? {
|
||||
nameservers: {
|
||||
global: [],
|
||||
split: {},
|
||||
},
|
||||
search_domains: [],
|
||||
extra_records: [],
|
||||
magic_dns: false,
|
||||
base_domain: 'headscale.net',
|
||||
},
|
||||
} as HeadscaleConfig
|
||||
|
||||
log.warn('CFGX', 'Loaded Headscale configuration in non-strict mode')
|
||||
log.warn('CFGX', 'By using this mode you forfeit GitHub issue support')
|
||||
log.warn('CFGX', 'This is very dangerous and comes with a few caveats:')
|
||||
log.warn('CFGX', 'Headplane could very easily crash')
|
||||
log.warn('CFGX', 'Headplane could break your Headscale installation')
|
||||
log.warn('CFGX', 'The UI could throw random errors/show incorrect data')
|
||||
log.warn('CFGX', '')
|
||||
return config
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug('CFGX', 'Attempting to parse Headscale configuration')
|
||||
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
|
||||
} catch (error) {
|
||||
log.debug('CFGX', 'Failed to load Headscale configuration')
|
||||
if (error instanceof z.ZodError) {
|
||||
log.error('CFGX', 'Recieved invalid configuration file')
|
||||
log.error('CFGX', 'The following schema issues were found:')
|
||||
for (const issue of error.issues) {
|
||||
const path = issue.path.map(String).join('.')
|
||||
const message = issue.message
|
||||
|
||||
log.error('CFGX', ` '${path}': ${message}`)
|
||||
}
|
||||
|
||||
log.error('CFGX', '')
|
||||
log.error('CFGX', 'Resolve these issues and try again.')
|
||||
log.error('CFGX', 'Headplane will operate without the config')
|
||||
log.error('CFGX', '')
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
|
||||
log.debug('CFGX', 'Patching Headscale configuration')
|
||||
for (const [key, value] of Object.entries(partial)) {
|
||||
log.debug('CFGX', 'Patching %s with %s', key, value)
|
||||
// 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('"', ''))
|
||||
if (value === null) {
|
||||
configYaml.deleteIn(path)
|
||||
continue
|
||||
}
|
||||
|
||||
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')
|
||||
log.debug('CFGX', 'Writing patched configuration to %s', path)
|
||||
await writeFile(path, configYaml.toString(), 'utf8')
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { loadContext } from './config/headplane'
|
||||
import log from './log'
|
||||
|
||||
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
|
||||
|
||||
log.debug('APIC', 'GET %s', `${prefix}/api/${url}`)
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug('APIC', 'GET %s failed with status %d', `${prefix}/api/${url}`, response.status)
|
||||
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
|
||||
|
||||
log.debug('APIC', 'POST %s', `${prefix}/api/${url}`)
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug('APIC', 'POST %s failed with status %d', `${prefix}/api/${url}`, response.status)
|
||||
throw new HeadscaleError(await response.text(), response.status)
|
||||
}
|
||||
|
||||
return (response.json() as Promise<T>)
|
||||
}
|
||||
|
||||
export async function put<T>(url: string, key: string, body?: unknown) {
|
||||
const context = await loadContext()
|
||||
const prefix = context.headscaleUrl
|
||||
|
||||
log.debug('APIC', 'PUT %s', `${prefix}/api/${url}`)
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug('APIC', 'PUT %s failed with status %d', `${prefix}/api/${url}`, response.status)
|
||||
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
|
||||
|
||||
log.debug('APIC', 'DELETE %s', `${prefix}/api/${url}`)
|
||||
const response = await fetch(`${prefix}/api/${url}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
log.debug('APIC', 'DELETE %s failed with status %d', `${prefix}/api/${url}`, response.status)
|
||||
throw new HeadscaleError(await response.text(), response.status)
|
||||
}
|
||||
|
||||
return (response.json() as Promise<T>)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
export default {
|
||||
info: (category: string, message: string, ...args: unknown[]) => {
|
||||
defaultLog('INFO', category, message, ...args)
|
||||
},
|
||||
|
||||
warn: (category: string, message: string, ...args: unknown[]) => {
|
||||
defaultLog('WARN', category, message, ...args)
|
||||
},
|
||||
|
||||
error: (category: string, message: string, ...args: unknown[]) => {
|
||||
defaultLog('ERRO', category, message, ...args)
|
||||
},
|
||||
|
||||
debug: (category: string, message: string, ...args: unknown[]) => {
|
||||
if (process.env.DEBUG === 'true') {
|
||||
defaultLog('DEBG', category, message, ...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultLog(
|
||||
level: string,
|
||||
category: string,
|
||||
message: string,
|
||||
...args: unknown[]
|
||||
) {
|
||||
const date = new Date().toISOString()
|
||||
console.log(`${date} (${level}) [${category}] ${message}`, ...args)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { redirect } from '@remix-run/node'
|
||||
import {
|
||||
authorizationCodeGrantRequest,
|
||||
calculatePKCECodeChallenge,
|
||||
Client,
|
||||
discoveryRequest,
|
||||
generateRandomCodeVerifier,
|
||||
generateRandomNonce,
|
||||
generateRandomState,
|
||||
getValidatedIdTokenClaims,
|
||||
isOAuth2Error,
|
||||
parseWwwAuthenticateChallenges,
|
||||
processAuthorizationCodeOpenIDResponse,
|
||||
processDiscoveryResponse,
|
||||
validateAuthResponse,
|
||||
} from 'oauth4webapi'
|
||||
|
||||
import { post } from '~/utils/headscale'
|
||||
import { commitSession, getSession } from '~/utils/sessions'
|
||||
|
||||
import { HeadplaneContext } from './config/headplane'
|
||||
|
||||
type OidcConfig = NonNullable<HeadplaneContext['oidc']>
|
||||
|
||||
export async function startOidc(oidc: OidcConfig, req: Request) {
|
||||
const session = await getSession(req.headers.get('Cookie'))
|
||||
if (session.has('hsApiKey')) {
|
||||
return redirect('/', {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const issuerUrl = new URL(oidc.issuer)
|
||||
const oidcClient = {
|
||||
client_id: oidc.client,
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
} satisfies Client
|
||||
|
||||
const response = await discoveryRequest(issuerUrl)
|
||||
const processed = await processDiscoveryResponse(issuerUrl, response)
|
||||
if (!processed.authorization_endpoint) {
|
||||
throw new Error('No authorization endpoint found on the OIDC provider')
|
||||
}
|
||||
|
||||
const state = generateRandomState()
|
||||
const nonce = generateRandomNonce()
|
||||
const verifier = generateRandomCodeVerifier()
|
||||
const challenge = await calculatePKCECodeChallenge(verifier)
|
||||
|
||||
const callback = new URL('/admin/oidc/callback', req.url)
|
||||
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
|
||||
callback.hostname = req.headers.get('Host') ?? ''
|
||||
const authUrl = new URL(processed.authorization_endpoint)
|
||||
|
||||
authUrl.searchParams.set('client_id', oidcClient.client_id)
|
||||
authUrl.searchParams.set('response_type', 'code')
|
||||
authUrl.searchParams.set('redirect_uri', callback.href)
|
||||
authUrl.searchParams.set('scope', 'openid profile email')
|
||||
authUrl.searchParams.set('code_challenge', challenge)
|
||||
authUrl.searchParams.set('code_challenge_method', 'S256')
|
||||
authUrl.searchParams.set('state', state)
|
||||
authUrl.searchParams.set('nonce', nonce)
|
||||
|
||||
session.set('authState', state)
|
||||
session.set('authNonce', nonce)
|
||||
session.set('authVerifier', verifier)
|
||||
|
||||
return redirect(authUrl.href, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function finishOidc(oidc: OidcConfig, req: Request) {
|
||||
const session = await getSession(req.headers.get('Cookie'))
|
||||
if (session.has('hsApiKey')) {
|
||||
return redirect('/', {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const issuerUrl = new URL(oidc.issuer)
|
||||
const oidcClient = {
|
||||
client_id: oidc.client,
|
||||
client_secret: oidc.secret,
|
||||
token_endpoint_auth_method: 'client_secret_basic',
|
||||
} satisfies Client
|
||||
|
||||
const response = await discoveryRequest(issuerUrl)
|
||||
const processed = await processDiscoveryResponse(issuerUrl, response)
|
||||
if (!processed.authorization_endpoint) {
|
||||
throw new Error('No authorization endpoint found on the OIDC provider')
|
||||
}
|
||||
|
||||
const state = session.get('authState')
|
||||
const nonce = session.get('authNonce')
|
||||
const verifier = session.get('authVerifier')
|
||||
if (!state || !nonce || !verifier) {
|
||||
throw new Error('No OIDC state found in the session')
|
||||
}
|
||||
|
||||
const parameters = validateAuthResponse(
|
||||
processed,
|
||||
oidcClient,
|
||||
new URL(req.url),
|
||||
state,
|
||||
)
|
||||
|
||||
if (isOAuth2Error(parameters)) {
|
||||
throw new Error('Invalid response from the OIDC provider')
|
||||
}
|
||||
|
||||
const callback = new URL('/admin/oidc/callback', req.url)
|
||||
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
|
||||
callback.hostname = req.headers.get('Host') ?? ''
|
||||
|
||||
const tokenResponse = await authorizationCodeGrantRequest(
|
||||
processed,
|
||||
oidcClient,
|
||||
parameters,
|
||||
callback.href,
|
||||
verifier,
|
||||
)
|
||||
|
||||
const challenges = parseWwwAuthenticateChallenges(tokenResponse)
|
||||
if (challenges) {
|
||||
throw new Error('Recieved a challenge from the OIDC provider')
|
||||
}
|
||||
|
||||
const result = await processAuthorizationCodeOpenIDResponse(
|
||||
processed,
|
||||
oidcClient,
|
||||
tokenResponse,
|
||||
nonce,
|
||||
)
|
||||
|
||||
if (isOAuth2Error(result)) {
|
||||
throw new Error('Invalid response from the OIDC provider')
|
||||
}
|
||||
|
||||
const claims = getValidatedIdTokenClaims(result)
|
||||
const expDate = new Date(claims.exp * 1000).toISOString()
|
||||
|
||||
const keyResponse = await post<{ apiKey: string }>(
|
||||
'v1/apikey',
|
||||
oidc.rootKey,
|
||||
{
|
||||
expiration: expDate,
|
||||
},
|
||||
)
|
||||
|
||||
session.set('hsApiKey', keyResponse.apiKey)
|
||||
session.set('user', {
|
||||
name: claims.name ? String(claims.name) : 'Anonymous',
|
||||
email: claims.email ? String(claims.email) : undefined,
|
||||
})
|
||||
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
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',
|
||||
secrets: [process.env.COOKIE_SECRET!],
|
||||
secure: process.env.COOKIE_SECURE !== 'false',
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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: 197 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 206 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 191 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 189 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 151 KiB |
@@ -0,0 +1,23 @@
|
||||
# THIS IS A DEVELOPER CONFIGURATION FILE
|
||||
# IT IS NOT AN EXAMPLE OF SOMETHING YOU DEPLOY
|
||||
# I ONLY USE IT FOR DEVELOPING HEADPLANE
|
||||
|
||||
networks:
|
||||
headplane-dev:
|
||||
name: 'headplane-dev'
|
||||
driver: 'bridge'
|
||||
services:
|
||||
headscale:
|
||||
image: 'headscale/headscale:0.23.0-beta2'
|
||||
container_name: 'headscale'
|
||||
restart: 'unless-stopped'
|
||||
command: 'serve'
|
||||
networks:
|
||||
- 'headplane-dev'
|
||||
volumes:
|
||||
- './.cache/headscale:/var/lib/headscale'
|
||||
- './test:/etc/headscale'
|
||||
ports:
|
||||
- '8080:8080'
|
||||
environment:
|
||||
TZ: 'America/New_York'
|
||||
@@ -0,0 +1,71 @@
|
||||
# Advanced Integration
|
||||
|
||||
The advanced integration methods unlock the full capabilities of Headplane.
|
||||
This is the closest you can get to the SaaS experience if you were paying for
|
||||
Tailscale.
|
||||
|
||||
### Configuration Management
|
||||
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="../assets/integration-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="../assets/integration-light.png"
|
||||
>
|
||||
<img
|
||||
alt="Integration Preview"
|
||||
src="../assets/integration-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
The advanced integration allows you to manage the Headscale configuration via
|
||||
the Headplane UI. When the configuration is available for editing, the `DNS`
|
||||
and `Settings` tabs will become available. When using the Docker or Kubernetes
|
||||
integration, changes to the configuration file will be automatically applied
|
||||
to Headscale.
|
||||
|
||||
> By default, the configuration file is read from `/etc/headscale/config.yaml`.
|
||||
This can be overridden by setting the `CONFIG_FILE` environment variable. Any
|
||||
variables including `HEADSCALE_URL`, `OIDC_CLIENT_ID`, `OIDC_ISSUER`, and
|
||||
`OIDC_CLIENT_SECRET` will take priority over the configuration file.
|
||||
|
||||
### Access Control Lists (ACLs)
|
||||
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
srcset="../assets/acls-dark.png"
|
||||
>
|
||||
<source
|
||||
media="(prefers-color-scheme: light)"
|
||||
srcset="../assets/acls-light.png"
|
||||
>
|
||||
<img
|
||||
alt="ACL Preview"
|
||||
src="../assets/acls-dark.png"
|
||||
>
|
||||
</picture>
|
||||
|
||||
The advanced integration allows you to manage the ACLs via the Headplane UI.
|
||||
When the ACL file is available for editing, the `Access Controls` tab will
|
||||
become available. All of the integrations support automatic reloading of the
|
||||
ACLs when the file is changed.
|
||||
|
||||
> By default, the ACL file is read from `/etc/headscale/acl_policy.json`.
|
||||
> If `policy.path` is set and `policy.mode` is set to `file`, the ACL file will
|
||||
> be read from the path specified in the configuration file instead.
|
||||
|
||||
## Deployment
|
||||
|
||||
Requirements:
|
||||
- Headscale 0.23 or newer
|
||||
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
|
||||
|
||||
Currently there are 3 integration providers that can do this for you:
|
||||
- [Docker Integration](/docs/integration/Docker.md)
|
||||
- [Kubernetes Integration](/docs/integration/Kubernetes.md)
|
||||
- [Native Linux Integration](/docs/integration/Native.md)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# Basic Integration
|
||||
|
||||
The basic integration is the simplest way to get started with Headplane.
|
||||
It's more of a preview and is heavily limited in the features it can offer
|
||||
when compared to the [Advanced Integration](/docs/Advanced-Integration.md).
|
||||
|
||||
> Note that the Advanced integration is the recommend way to run
|
||||
Headplane in a production environment.
|
||||
|
||||
## Limitations
|
||||
- No automatic management of Access Control Lists (ACLs)
|
||||
- No management of DNS settings for your tailnet
|
||||
- No capability to edit the configuration
|
||||
- Limited support for OIDC authentication
|
||||
|
||||
## Deployment
|
||||
|
||||
Requirements:
|
||||
- Headscale 0.23 or newer
|
||||
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
|
||||
|
||||
Docker heavily simplifies the deployment process, but this process can be
|
||||
adopted to run natively. Follow the first section of the deployment guide
|
||||
in the [Native Integration](/docs/integration/Native.md#deployment) for a
|
||||
bare-metal or virtual machine deployment.
|
||||
|
||||
Here is a simple Docker Compose deployment:
|
||||
```yaml
|
||||
services:
|
||||
headplane:
|
||||
container_name: headplane
|
||||
image: ghcr.io/tale/headplane:0.3.0
|
||||
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'
|
||||
COOKIE_SECURE: 'false'
|
||||
|
||||
# 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.
|
||||
@@ -0,0 +1,57 @@
|
||||
# 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
|
||||
|
||||
- **`DEBUG`**: Enable debug logging (default: `false`).
|
||||
- **`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`).
|
||||
- **`HEADSCALE_CONFIG_UNSTRICT`**: This will disable the strict configuration loader (default: `false`).
|
||||
- **`COOKIE_SECURE`**: This option enables the `Secure` flag for cookies, ensuring they are sent only over HTTPS, which helps prevent interception and enhances data security. It should be disabled when using HTTP instead of HTTPS (default: `true`).
|
||||
|
||||
#### Docker Integration
|
||||
The Docker integration allows Headplane to manage the Headscale docker container.
|
||||
You'll need to provide these variables if you want to use this feature.
|
||||
Keep in mind that `DOCKER_SOCK` must start with a protocol (e.g., `unix://`).
|
||||
Secure API is currently not supported.
|
||||
|
||||
- **`DOCKER_SOCK`**: The protocol and path to the Docker socket (default: `unix:///var/run/docker.sock`).
|
||||
- **`HEADSCALE_CONTAINER`**: The name of the Headscale container (required for Docker integration).
|
||||
|
||||
### SSO/OpenID Connect
|
||||
If you want to use OpenID Connect for SSO, you'll need to provide these variables.
|
||||
Headplane will utilize the expiry of your tokens to determine the expiry of the session.
|
||||
If you use the Headscale configuration integration, these are not required.
|
||||
|
||||
- **`OIDC_ISSUER`**: The issuer URL of your OIDC provider.
|
||||
- **`OIDC_CLIENT_ID`**: The client ID of your OIDC provider.
|
||||
- **`OIDC_CLIENT_SECRET`**: The client secret of your OIDC provider.
|
||||
- **`ROOT_API_KEY`**: An API key used to issue new ones for sessions (keep expiry fairly long).
|
||||
- **`DISABLE_API_KEY_LOGIN`**: If you want to disable API key login, set this to `true`.
|
||||
|
||||
Here's what an example with Authelia would look like if you used the same client for both Headscale and Headplane.
|
||||
Keep in mind that the recommended deployment would be putting Headplane behind /admin on a reverse proxy.
|
||||
If you use a different domain than the Headscale server, you'll need to make sure that Headscale responds with CORS headers.
|
||||
|
||||
```yaml
|
||||
- client_id: 'headscale'
|
||||
client_name: 'Headscale and Headplane'
|
||||
public: false
|
||||
authorization_policy: 'two_factor'
|
||||
redirect_uris:
|
||||
- 'https://headscale.example.com/oidc/callback'
|
||||
- 'https://headscale.example.com/admin/oidc/callback'
|
||||
scopes:
|
||||
- 'openid'
|
||||
- 'profile'
|
||||
- 'email'
|
||||
userinfo_signed_response_alg: 'none'
|
||||
client_secret: 'my_super_secret_client_secret'
|
||||
```
|
||||
@@ -0,0 +1,89 @@
|
||||
## Docker Integration
|
||||
|
||||
The Docker integration allows you to run Headplane and Headscale separately
|
||||
in a dockerized environment. It allows you to unlock full functionality such as
|
||||
automatic reloading of ACLs, DNS management, and Headscale configuration
|
||||
management.
|
||||
|
||||
### Deployment
|
||||
|
||||
> When running with the Docker integration, it's assumed that both Headscale and
|
||||
Headplane will run as containers. If you are running Headscale natively, then
|
||||
refer to the [Native Integration](/docs/integration/Native.md) guide.
|
||||
|
||||
To enable the Docker integration, set the `HEADSCALE_INTEGRATION` environment
|
||||
variable to `docker`. You'll also need to supply `HEADSCALE_CONTAINER` with the
|
||||
name or ID of the Headscale container.
|
||||
|
||||
By default Headplane uses `unix:///var/run/docker.sock` to connect to Docker.
|
||||
This can be overridden by setting the `DOCKER_SOCK` environment variable. For
|
||||
example, a remote socket would be `tcp://<my-remote-host>:2375`. When setting
|
||||
the variable, you'll need to specify the protocol (`unix://` or `tcp://`).
|
||||
|
||||
> The `DOCKER_SOCK` variable does not support the HTTPS protocol.
|
||||
|
||||
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.
|
||||
|
||||
Here's an example deployment using Docker Compose (recommended). Keep in mind
|
||||
that you'll NEED to setup a reverse proxy and this is incomplete:
|
||||
```yaml
|
||||
services:
|
||||
headscale:
|
||||
image: 'headscale/headscale:0.23.0'
|
||||
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:0.3.0
|
||||
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'
|
||||
|
||||
# Only set this to false if you aren't behind a reverse proxy
|
||||
COOKIE_SECURE: 'false'
|
||||
|
||||
# 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.
|
||||
@@ -0,0 +1,133 @@
|
||||
## Kubernetes Integration
|
||||
|
||||
The Kubernetes integration allows you to run Headplane and Headscale together
|
||||
in a cluster. It allows you to unlock full functionality such as automatic
|
||||
reloading of ACLs, DNS management, and Headscale configuration management.
|
||||
|
||||
Currently there are a few limitations to the Kubernetes integration:
|
||||
- Headplane and Headscale need to run in the same Pod and share the same
|
||||
process space for the integration to work correctly due to a limitation in
|
||||
the Kubernetes API.
|
||||
|
||||
- The only supported methods of deploying the integration are through a
|
||||
`Deployment` or `Pod` (more coming soon). You can still get around this with
|
||||
the `HEADSCALE_INTEGRATION_UNSTRICT` variable, but it's not recommended.
|
||||
|
||||
- The integration will assume that the Headscale container will always restart
|
||||
because the integration relies on a system call that will exit the container.
|
||||
|
||||
### Deployment
|
||||
|
||||
In order to ensure Headplane can read Kubernetes resources, you'll need to
|
||||
grant additional RBAC permissions to the default `ServiceAccount` in the
|
||||
namespace. This can be done with the following:
|
||||
```yaml
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
name: headplane-agent
|
||||
namespace: default # Adjust namespace as needed
|
||||
rules:
|
||||
- apiGroups: ['']
|
||||
resources: ['pods']
|
||||
verbs: ['get', 'list']
|
||||
- apiGroups: ['apps']
|
||||
resources: ['deployments']
|
||||
verbs: ['get', 'list']
|
||||
---
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: RoleBinding
|
||||
metadata:
|
||||
name: headplane-agent
|
||||
namespace: default # Adjust namespace as needed
|
||||
roleRef:
|
||||
apiGroup: rbac.authorization.k8s.io
|
||||
kind: Role
|
||||
name: headplane-agent
|
||||
subjects:
|
||||
- kind: ServiceAccount
|
||||
name: default # If you use a different service account, change this
|
||||
namespace: default # Adjust namespace as needed
|
||||
```
|
||||
|
||||
Keep in mind you'll need to make `PersistentVolumeClaim`s for the data and that
|
||||
they need to be either `ReadWriteOnce` or `ReadWriteMany` depending on your
|
||||
topology. Additionally, you can abstract environment variables and configuration
|
||||
away into a `ConfigMap` or `Secret` for easier management.
|
||||
|
||||
The important parts of this deployment are the `HEADSCALE_INTEGRATION` and
|
||||
`DEPLOYMENT_NAME` environment variables. The `HEADSCALE_INTEGRATION` variable
|
||||
should be set to `kubernetes` and the `POST_NAME` variable should be set
|
||||
to the name of the pod (done using the Downward API below).
|
||||
|
||||
> If you are having issues with validating `shareProcessNamespace`, you can
|
||||
set `HEADSCALE_INTEGRATION_UNSTRICT` to `true` to disable the strict checks.
|
||||
|
||||
A basic deployment of the integration would look like this. Keep in mind that
|
||||
you are responsible for setting up a reverse-proxy via an `Ingress` or `Service`
|
||||
otherwise Headplane will not work:
|
||||
```yaml
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: headplane
|
||||
namespace: default # Adjust namespace as needed
|
||||
labels:
|
||||
app: headplane
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app: headplane
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: headplane
|
||||
spec:
|
||||
shareProcessNamespace: true
|
||||
serviceAccountName: default
|
||||
containers:
|
||||
- name: headplane
|
||||
image: ghcr.io/tale/headplane:0.3.0
|
||||
env:
|
||||
- name: COOKIE_SECRET
|
||||
value: 'abcdefghijklmnopqrstuvwxyz'
|
||||
- name: HEADSCALE_INTEGRATION
|
||||
value: 'kubernetes'
|
||||
- name: POD_NAME
|
||||
valueFrom:
|
||||
fieldRef:
|
||||
fieldPath: metadata.name
|
||||
|
||||
# Only set this to false if you aren't behind a reverse proxy
|
||||
- name: COOKIE_SECURE
|
||||
value: 'false'
|
||||
volumeMounts:
|
||||
- name: headscale-config
|
||||
mountPath: /etc/headscale
|
||||
|
||||
- name: headscale
|
||||
image: headscale/headscale:0.23.0
|
||||
command: ['serve']
|
||||
env:
|
||||
- name: TZ
|
||||
value: 'America/New_York'
|
||||
volumeMounts:
|
||||
- name: headscale-data
|
||||
mountPath: /var/lib/headscale
|
||||
- name: headscale-config
|
||||
mountPath: /etc/headscale
|
||||
|
||||
volumes:
|
||||
- name: headscale-data
|
||||
persistentVolumeClaim:
|
||||
claimName: headscale-data
|
||||
- name: headscale-config
|
||||
persistentVolumeClaim:
|
||||
claimName: headscale-config
|
||||
```
|
||||
|
||||
> 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.
|
||||
@@ -0,0 +1,28 @@
|
||||
## Native Integration
|
||||
|
||||
The Native integration allows you to run both Headplane and Headscale on
|
||||
bare-metal servers or virtual machines. This integration is best suited for
|
||||
environments where Docker or Kubernetes are not available or not desired.
|
||||
|
||||
Currently the Native integration only supports automatic reloading of ACLs. It
|
||||
cannot handle configuration changes as killing the `headscale` process can lead
|
||||
to undefined behavior or the service not restarting.
|
||||
|
||||
### Deployment
|
||||
|
||||
Follow the instructions to install Headscale from the
|
||||
[Linux Installation Guide](https://headscale.net/running-headscale-linux/). As
|
||||
of now, Headplane requires Node.js 20 to be installed on the system. Once you
|
||||
are ready, clone the repository (`git clone https://github.com/tale/headplane`),
|
||||
install dependencies (`npm install`), build the project (`npm run build`), and
|
||||
start the server (`npm start`).
|
||||
|
||||
> If you'd like, you can turn this into a `systemd` unit to manage the service.
|
||||
> I plan to provide packages and unit files to make this easier in the future.
|
||||
|
||||
When running Headplane, you'll need to set environment variables to configure
|
||||
the application. The `HEADSCALE_INTEGRATION` variable should be set to `proc`.
|
||||
|
||||
> 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.
|
||||
@@ -0,0 +1,5 @@
|
||||
import tale from 'eslint-config-tale'
|
||||
|
||||
export default [
|
||||
...tale
|
||||
]
|
||||
Generated
-4268
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user