Compare commits

...

109 Commits

Author SHA1 Message Date
Aarnav Tale b433e607e2 chore: v0.3.6 2024-11-20 18:15:19 -05:00
Aarnav Tale 8aad883c21 fix: make selects scrollable 2024-11-20 18:13:49 -05:00
Aarnav Tale 3cd28d2136 feat: make the code snippets copyable 2024-11-20 18:01:20 -05:00
Aarnav Tale 9d9cbd8e0e chore: v0.3.5 2024-11-08 12:05:33 -05:00
Aarnav Tale feb8b8bba5 chore: remove logging on expected errors 2024-11-08 12:04:24 -05:00
Aarnav Tale c304effcdf fix: externalize process.env 2024-11-08 12:03:08 -05:00
Aarnav Tale 982d811d53 chore: v0.3.4 2024-11-07 13:09:35 -05:00
Aarnav Tale d369e36fa4 fix: potentially catch undefined bearer errors 2024-11-07 13:08:43 -05:00
Aarnav Tale a2e659f36c feat(TALE-36): redesign machines page to account for exit nodes 2024-11-07 13:04:53 -05:00
Aarnav Tale 1490406784 feat(TALE-36): show exit node status on machine page 2024-11-06 17:01:36 -05:00
Aarnav Tale e1c87412d4 feat(TALE-36): support exit node enabling/disabling 2024-11-06 16:48:14 -05:00
Aarnav Tale 6e55f442fd fix: make the last acl tag removable on machines 2024-11-06 16:10:39 -05:00
Aarnav Tale 09e1b1f261 fix: address a bug that prevented remove split dns records 2024-11-06 15:57:43 -05:00
Aarnav Tale 3e83f8617b fix: use correct vite config in dev 2024-11-06 15:57:27 -05:00
Aarnav Tale 1d6472765a docs: better explain the native integration 2024-11-06 15:18:21 -05:00
Aarnav Tale 2bc85085f5 fix: handle non-prefixed routes on the production server 2024-11-06 14:29:04 -05:00
Aarnav Tale 5c949e2da5 feat: use all native node deps for the server 2024-11-04 22:05:46 -05:00
Aarnav Tale 12754bd0aa fix: resolve type errors across the codebase 2024-11-04 18:13:10 -05:00
Aarnav Tale 10bb4eba97 fix: skip loading env file if context is loaded 2024-11-04 18:00:50 -05:00
Aarnav Tale 13a734923d feat: ditch remix-serve for custom-baked server 2024-11-04 18:00:35 -05:00
Aarnav Tale d5ee8ae0f3 fix: clicking a machine name in users page redirects 2024-11-04 14:59:13 -05:00
Aarnav Tale 331a9c8dcf chore: v0.3.3 2024-10-28 15:10:20 -04:00
Aarnav Tale 808b01c5d7 chore: update react 19 compiler to next beta 2024-10-27 13:13:34 -04:00
Aarnav Tale a8abd37e3a fix: handle errors and fix api logic with acl updating 2024-10-27 13:10:32 -04:00
Aarnav Tale d1fa76971b feat: switch back to codemirror with jsonc support 2024-10-22 00:41:44 -04:00
Aarnav Tale a9e8394dec fix: do not pass the text value as a form field
This fixes an issue where on ephemeral key creation, it would fail due to localized dates
2024-10-20 18:34:43 -04:00
Aarnav Tale 9dafd8e8b0 feat: load from dotenv if specified 2024-10-16 23:07:41 -04:00
Václav Šmejkal 0c1f6969da feat: hide api key on login page (#39) 2024-10-16 13:23:02 -04:00
Aarnav Tale 4214e14ba8 chore: reorganize machines page to prep for routing data 2024-10-13 00:32:21 -04:00
Aarnav Tale 447a31f91e chore: v0.3.2 2024-10-11 03:09:56 -04:00
Aarnav Tale 9e21823163 chore: mention that settings is incomplete 2024-10-11 03:04:45 -04:00
Aarnav Tale aa7e2a3128 feat(TALE-35): implement pre-auth key management 2024-10-11 03:02:33 -04:00
Aarnav Tale ecef45c98a fix(TALE-35): use the correct machine registry endpoint 2024-10-10 11:12:35 -04:00
Aarnav Tale d165264876 fix: handle nullable expiry values from a db 2024-10-10 10:41:41 -04:00
Aarnav Tale dd479d4117 fix: remove older references to magic_dns via usernames 2024-10-10 10:03:43 -04:00
Akira Yamazaki 29d91785fb fix: build oidc callback url in a more proper way (#28) 2024-10-07 23:23:31 -04:00
Aarnav Tale 98d1cb1333 docs: mention 0.3.1 as the latest 2024-10-03 15:36:20 -04:00
Aarnav Tale 2229f547a9 chore: v0.3.1 2024-10-03 15:31:03 -04:00
Aarnav Tale 65cc278a59 docs(TALE-33): document debug 2024-10-03 15:30:24 -04:00
Aarnav Tale 1555846df2 fix(TALE-34): use the http coded URL for socket 2024-10-03 15:15:56 -04:00
Aarnav Tale e8c1cadf54 feat(TALE-35): add initial machine key authorization 2024-10-03 11:58:05 -04:00
Aarnav Tale d867769025 chore: update browserlist 2024-10-02 13:41:45 -04:00
Aarnav Tale 1d6066d3f0 feat(TALE-33): add debug logging with DEBUG env 2024-10-02 13:33:39 -04:00
Aarnav Tale 1d821251a9 fix(TALE-34): setting url.protocol does not work anymore 2024-10-02 12:59:42 -04:00
Aarnav Tale a0d6905123 chore: v0.3.0 2024-09-25 16:32:58 -04:00
Aarnav Tale 4095ed2a68 docs: version the docker images 2024-09-25 16:26:19 -04:00
Aarnav Tale 58e98278d1 chore: support dns use_username_in_magic_dns config 2024-09-25 16:22:00 -04:00
Aarnav Tale 90f0bf2555 chore: v0.2.4 2024-08-24 10:35:05 -04:00
Aarnav Tale ea2ffdf0c1 feat: support removing config values via null 2024-08-24 10:33:30 -04:00
Aarnav Tale 9aedd9baad chore: use beta2 on the dev env 2024-08-24 10:19:07 -04:00
Aarnav Tale 690b52d8c6 chore(TALE-29): remove acl from integration/context 2024-08-24 10:19:07 -04:00
Aarnav Tale a72a3d6e5f chore(TALE-29): remove references to ACL_FILE 2024-08-24 10:19:06 -04:00
Akira Yamazaki c4c1fd8aab feat: make secure flag of cookie configurable (#26) 2024-08-24 10:18:38 -04:00
Aarnav Tale 9801ef453d fix(TALE-29): remove all old ACL_FILE handling
No longer required if the minimum is beta2
2024-08-23 16:12:46 -04:00
Aarnav Tale d041a62fcd chore: 0.2.3 2024-08-23 15:20:27 -04:00
Aarnav Tale 0e6b5ea6d0 chore: update docs for minimum beta requirement 2024-08-23 15:18:32 -04:00
Aarnav Tale a2054786f8 fix(TALE-31): use oidc variables first over config 2024-08-23 15:12:53 -04:00
Aarnav Tale 5a46fd0a97 feat(TALE-30): add support for new DNS configs
This is a breaking change to support 0.23-beta2
2024-08-22 16:55:05 -04:00
Aarnav Tale b8999161a2 feat(TALE-29): handle no ACL configurations being available 2024-08-04 17:30:41 -04:00
Aarnav Tale 224cbbdcaf chore: update to headscale beta for compose dev 2024-08-04 11:33:11 -04:00
Aarnav Tale 75ba3a3dc7 feat(TALE-29): support the headscale policy api changes 2024-08-04 11:32:29 -04:00
Aarnav Tale 4f57fdb43b chore: v0.2.2 2024-08-02 16:15:08 -04:00
Aarnav Tale b170e11dd6 fix(TALE-7): don't destructure context otherwise it won't set properly 2024-08-02 16:14:05 -04:00
Aarnav Tale 6b278309ed fix(TALE-7): use the new integration methods in the code 2024-07-10 19:38:44 -04:00
Aarnav Tale ea8ecfb28f chore: use new logger 2024-07-10 19:36:13 -04:00
Aarnav Tale 099bd3bcb8 feat(TALE-7): add proper integration logging 2024-07-10 19:26:59 -04:00
Aarnav Tale 0aa0406ea6 feat(TALE-7): reimplement integration system 2024-07-09 22:53:00 -04:00
Aarnav Tale 3cc726320a fix(TALE-5): remove deployment check and only use pod spec 2024-07-08 14:40:38 -04:00
Aarnav Tale bda151f4e8 chore: simplify documentation 2024-07-08 14:31:53 -04:00
Aarnav Tale 6d411853d5 feat(TALE-5): implement k8s integration 2024-07-08 13:23:53 -04:00
Aarnav Tale dc4d05a2d9 fix: filter out undefined proc pids 2024-07-08 01:17:28 -04:00
Aarnav Tale 361859f374 chore: v0.2.1 2024-07-07 14:57:33 -04:00
Aarnav Tale c60652e39f fix(TALE-4): prevent duplicate ACL tags from being added 2024-07-07 14:57:16 -04:00
Aarnav Tale 1db385e716 chore(TALE-11): remove unnecessary component in dns 2024-07-07 14:53:16 -04:00
Aarnav Tale ab0cb7b782 feat(TALE-11): support custom DNS records 2024-07-07 14:52:47 -04:00
Aarnav Tale fd73832879 fix: use acl diff editor and make discard button work 2024-07-07 14:28:28 -04:00
Aarnav Tale 7804d83181 feat(TALE-4): make acl tags editable from the menu 2024-07-07 14:20:39 -04:00
Aarnav Tale fe40a5734e chore: update deps 2024-07-07 14:19:53 -04:00
Aarnav Tale c0bdf91f6b chore: v0.2.0 2024-06-23 18:21:37 -04:00
Aarnav Tale bd46f97121 chore: update deps 2024-06-23 18:18:01 -04:00
Aarnav Tale d71e55d7af feat(TALE-10): edit machine owner via dropdown 2024-06-23 17:25:33 -04:00
Aarnav Tale ddd20fe027 feat: create select component 2024-06-23 17:25:22 -04:00
Aarnav Tale dca5f9d149 fix(TALE-6): make menu dropdown actions available on machines page 2024-06-23 01:05:03 -04:00
Aarnav Tale be8ce7a2fe fix: set defaults with HEADSCALE_UNSTRICT 2024-06-18 23:59:06 -04:00
Aarnav Tale 6fa0e265fe feat: update images 2024-06-03 23:33:38 -04:00
Aarnav Tale 74e87bd80e chore: v0.1.9 2024-06-03 22:52:26 -04:00
Aarnav Tale d01699009d chore: update packages 2024-06-03 22:51:45 -04:00
Aarnav Tale 08db192db1 fix: use magic domain for machine domain attribute 2024-06-03 22:43:38 -04:00
Aarnav Tale 3ea5fed8f6 fix: support jsonc comments and switch to monaco for acls 2024-06-02 22:40:23 -04:00
Aarnav Tale dd9d6cd550 fix: set edge tag to main 2024-06-02 16:52:29 -04:00
Aarnav Tale f82244f85a fix: github actions work this time? 2024-06-02 14:33:43 -04:00
Aarnav Tale 896aaad61e fix: push action had syntax error 2024-06-02 14:29:32 -04:00
Aarnav Tale e5dece5b7b feat: add build and nightly 2024-06-02 14:28:42 -04:00
Aarnav Tale 2797525969 fix: expired check 2.0? 2024-06-02 14:03:25 -04:00
Aarnav Tale f0f8a6f01b chore: v0.1.8 2024-06-02 01:40:05 -04:00
Aarnav Tale 3bd5cc99aa fix: handle 0001-01-01 00:00:00 as the no-expiry timestamp 2024-06-02 01:37:31 -04:00
Aarnav Tale 937fa6f6fa fix: use proper route ids for key mapping 2024-06-02 01:34:58 -04:00
Aarnav Tale 3ffbabd7fc feat: add functional machine overview page and fix types 2024-06-02 01:33:40 -04:00
Aarnav Tale c08203cc76 fix: prevent user deletion if they have machines owned 2024-06-02 00:54:20 -04:00
Aarnav Tale 57f045eaf6 fix: support top level await on the server 2024-05-30 11:41:36 -04:00
Aarnav Tale faa583bb38 chore: v0.1.7 2024-05-30 10:41:55 -04:00
Aarnav Tale 98ea2cb06f feat: add a way to escape hatch from strict config checking 2024-05-30 10:41:40 -04:00
Aarnav Tale 868d85bbeb chore: log about context loading 2024-05-30 10:28:37 -04:00
Aarnav Tale dbd9d39da9 style: cleanup vite.config.ts 2024-05-30 10:20:53 -04:00
Aarnav Tale 72cc9d8974 fix: flexible duration and go boolean handling 2024-05-30 10:20:41 -04:00
Aarnav Tale ed50c48965 fix: handle configs better and propagate errors 2024-05-30 10:15:02 -04:00
Aarnav Tale e19dbda5ed fix: headscale expiry can be a literal 0 2024-05-26 19:41:56 -04:00
Aarnav Tale a72cc1bb1c chore: document the new integration providers 2024-05-26 19:40:46 -04:00
Aarnav Tale f0e4868252 feat: configure support for generic integrations
Co-authored-by: Gage Orsburn <gageorsburn@live.com>
2024-05-26 19:23:24 -04:00
95 changed files with 7278 additions and 5194 deletions
+44
View File
@@ -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
+44
View File
@@ -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
+90 -1
View File
@@ -1,4 +1,93 @@
### 0.1.5 (May 22, 2024)
### 0.3.6 (November 20, 2024)
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
### 0.3.5 (November 8, 2024)
- Quickfix a bug where environment variables are ignored on the server.
- Remove a nagging error about missing cookie since that happens when signed out.
### 0.3.4 (November 7, 2024)
- Clicking on the machine name in the users page now takes you to the machine overview page.
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
- Improve documentation and support when running Headplane outside of Docker.
- Removing Split DNS records will no longer result in an error (fixes [#40](https://github.com/tale/headplane/issues/40))
- Removing the last ACL tag on a machine no longer results in an error (fixes [#41](https://github.com/tale/headplane/issues/41))
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
- Added a basic check to see if the API keys passed via cookies are invalid.
### 0.3.3 (October 28, 2024)
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
- Improved ACL editor performance by switching back to CodeMirror 6
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
### 0.3.2 (October 11, 2024)
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
- Handle `null` values on machine expiry when using a database like PostgreSQL.
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
### 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.
+1 -1
View File
@@ -19,4 +19,4 @@ 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" ]
CMD [ "node", "./build/headplane/server.js" ]
+25 -4
View File
@@ -1,7 +1,20 @@
# Headplane
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale)
![Preview](/assets/main-preview.png)
<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>
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.
@@ -13,9 +26,17 @@ It's able to replicate nearly all of the functions of the official Tailscale Saa
- DNS and *safe* Headscale configuration management
## Deployment
- If you run Headscale in a Docker container, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
- If you run Headscale natively, see the [Basic Deployment](/docs/Basic-Integration.md) guide.
- For more configuration options, refer to the [Configuration](/docs/Configuration.md) guide.
> 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 PNPM.
+7 -2
View File
@@ -6,14 +6,19 @@ interface Props {
name: string
value: string
isCopyable?: boolean
link?: string
}
export default function Attribute({ name, value, isCopyable }: Props) {
export default function Attribute({ name, value, link, 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}
{link ? (
<a className="hover:underline" href={link}>
{name}
</a>
) : name}
</dt>
{canCopy
+40 -7
View File
@@ -1,12 +1,45 @@
import clsx from 'clsx'
import { type HTMLProps } from 'react'
import { useState, HTMLProps } from 'react'
import { CopyIcon, CheckIcon } from '@primer/octicons-react'
type Properties = HTMLProps<HTMLSpanElement>
import { cn } from '~/utils/cn'
import { toast } from '~/components/Toaster'
interface Props extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean
}
export default function Code(props: Props) {
const [isCopied, setIsCopied] = useState(false)
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>
<>
<code className={cn(
'bg-ui-100 dark:bg-ui-800 p-0.5 rounded-md',
props.className
)}>
{props.children}
</code>
{props.isCopyable && (
<button
className={cn(
'ml-1 p-1 rounded-md',
'bg-ui-100 dark:bg-ui-800',
'text-ui-500 dark:text-ui-400',
'inline-flex items-center justify-center'
)}
onClick={() => {
navigator.clipboard.writeText(props.children as string)
toast('Copied to clipboard')
setIsCopied(true)
setTimeout(() => setIsCopied(false), 1000)
}}
>
{isCopied ?
<CheckIcon className="h-3 w-3" /> :
<CopyIcon className="h-3 w-3" />
}
</button>
)}
</>
)
}
+5 -6
View File
@@ -9,7 +9,10 @@ import Menu from './Menu'
import TabLink from './TabLink'
interface Properties {
readonly data?: HeadplaneContext & { user?: SessionData['user'] }
readonly data?: {
config: HeadplaneContext['config']
user?: SessionData['user']
}
}
interface LinkProperties {
@@ -98,11 +101,7 @@ export default function Header({ data }: Properties) {
<nav className="container flex items-center gap-x-4 overflow-x-auto">
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
{data?.acl.read
? (
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
)
: undefined}
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
{data?.config.read
? (
<>
+10 -11
View File
@@ -2,30 +2,29 @@ import { LinkExternalIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
/* eslint-disable unicorn/no-keyword-prefix */
type Properties = {
readonly to: string;
readonly name: string;
readonly children: string;
readonly className?: string;
interface Props {
to: string
name: string
children: string
className?: string
}
export default function Link({ to, name: alt, children, className }: Properties) {
export default function Link({ to, name: alt, children, className }: Props) {
return (
<a
href={to}
aria-label={alt}
target='_blank'
rel='noreferrer'
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
className,
)}
>
{children}
<LinkExternalIcon className='h-3 w-3'/>
<LinkExternalIcon className="h-3 w-3" />
</a>
)
}
+14 -7
View File
@@ -1,15 +1,22 @@
import { InfoIcon } from '@primer/octicons-react'
import clsx from 'clsx'
import { type ReactNode } from 'react'
import type { ReactNode } from 'react'
export default function Notice({ children }: { readonly children: ReactNode }) {
import { cn } from '~/utils/cn'
interface Props {
className?: string
children: ReactNode
}
export default function Notice({ children, className }: Props) {
return (
<div className={clsx(
'p-4 rounded-md w-fit flex items-center gap-3',
'bg-slate-400 dark:bg-slate-700'
<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-white'/>
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
{children}
</div>
)
+47
View File
@@ -0,0 +1,47 @@
import { PlusIcon, DashIcon } from '@primer/octicons-react'
import { Dispatch, SetStateAction } from 'react'
import {
Button,
Group,
Input,
NumberField as AriaNumberField
} from 'react-aria-components'
import { cn } from '~/utils/cn'
type NumberFieldProps = Parameters<typeof AriaNumberField>[0] & {
label: string;
state?: [number, Dispatch<SetStateAction<number>>];
}
export default function NumberField(props: NumberFieldProps) {
return (
<AriaNumberField
{...props}
aria-label={props.label}
className="w-full"
value={props.state?.[0]}
onChange={value => {
props.state?.[1](value)
}}
>
<Group className={cn(
'flex 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 gap-2',
'focus-within:ring-2 focus-within:ring-blue-600',
props.className
)}>
<Input
className="w-full bg-transparent focus:outline-none"
/>
<Button slot="decrement">
<DashIcon className="w-4 h-4" />
</Button>
<Button slot="increment">
<PlusIcon className="w-4 h-4" />
</Button>
</Group>
</AriaNumberField>
)
}
+82
View File
@@ -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',
'z-50 overflow-y-auto',
'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 orientation="vertical">
{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 })
+10 -4
View File
@@ -1,7 +1,7 @@
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 { AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast'
import { ReactNode, useRef } from 'react'
import { Button } from 'react-aria-components'
import { createPortal } from 'react-dom'
import { ClientOnly } from 'remix-utils/client-only'
@@ -14,6 +14,8 @@ type ToastProperties = AriaToastProps<ReactNode> & {
function Toast({ state, ...properties }: ToastProperties) {
const reference = useRef(null)
// @ts-expect-error: RefObject doesn't map to FocusableElement?
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
return (
@@ -52,11 +54,15 @@ export function toast(text: string) {
export function Toaster() {
const reference = useRef(null)
const state = useToastQueue(toasts)
// @ts-expect-error: React 19 has weird types for Portal vs Node
const { regionProps } = useToastRegion({}, state, reference)
return (
<ClientOnly>
{() => createPortal(
{
// @ts-expect-error: Portal doesn't match Node in React 19 yet
() => createPortal(
state.visibleToasts.length >= 0 ? (
<div
className={cn(
+62
View File
@@ -0,0 +1,62 @@
import { PassThrough } from 'node:stream'
import type { AppLoadContext, EntryContext } from '@remix-run/node'
import { createReadableStreamFromReadable } from '@remix-run/node'
import { RemixServer } from '@remix-run/react'
import { isbot } from 'isbot'
import { renderToPipeableStream } from 'react-dom/server'
import { loadContext } from './utils/config/headplane'
await loadContext()
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
_loadContext: AppLoadContext,
) {
const ua = request.headers.get('user-agent')
const isBot = ua ? isbot(ua) : false
return new Promise((resolve, reject) => {
let shellRendered = false
const { pipe, abort } = renderToPipeableStream(
<RemixServer
context={remixContext}
url={request.url}
abortDelay={5000}
/>,
{
[isBot ? 'onAllReady' : 'onShellReady']() {
shellRendered = true
const body = new PassThrough()
const stream = createReadableStreamFromReadable(body)
responseHeaders.set('Content-Type', 'text/html')
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
)
pipe(body)
},
onShellError(error: unknown) {
reject(error as Error)
},
onError(error: unknown) {
responseStatusCode = 500
if (shellRendered) {
console.error(error)
}
},
},
)
setTimeout(abort, 5000)
})
}
+156
View File
@@ -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}`)
}
}
},
})
+75
View File
@@ -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
}
+13
View File
@@ -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
}
+204
View File
@@ -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')
},
})
+79
View File
@@ -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
}
}
})
+122
View File
@@ -0,0 +1,122 @@
import React, { useEffect } from 'react'
import Merge from 'react-codemirror-merge'
import CodeMirror from '@uiw/react-codemirror'
import * as shopify from '@shopify/lang-jsonc'
import { ClientOnly } from 'remix-utils/client-only'
import { ErrorBoundary } from 'react-error-boundary'
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
import { useState } from 'react'
import { cn } from '~/utils/cn'
import Fallback from './fallback'
interface EditorProps {
isDisabled?: boolean
value: string
onChange: (value: string) => void
}
export function Editor(props: EditorProps) {
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">
<ErrorBoundary fallback={
<p className={cn(
'w-full h-full flex items-center justify-center',
'text-gray-400 dark:text-gray-500 text-xl',
)}>
Failed to load the editor.
</p>
}>
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => (
<CodeMirror
value={props.value}
height="100%"
extensions={[shopify.jsonc()]}
style={{ height: "100%" }}
theme={light ? githubLight : githubDark}
onChange={(value) => props.onChange(value)}
/>
)}
</ClientOnly>
</ErrorBoundary>
</div>
</div>
)
}
interface DifferProps {
left: string
right: string
}
export function Differ(props: DifferProps) {
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">
{props.left === props.right ? (
<p className={cn(
'w-full h-full flex items-center justify-center',
'text-gray-400 dark:text-gray-500 text-xl',
)}>
No changes
</p>
) : (
<ErrorBoundary fallback={
<p className={cn(
'w-full h-full flex items-center justify-center',
'text-gray-400 dark:text-gray-500 text-xl',
)}>
Failed to load the editor.
</p>
}>
<ClientOnly fallback={<Fallback acl={props.right} />}>
{() => (
<Merge
orientation="a-b"
theme={light ? githubLight : githubDark}
>
<Merge.Original
readOnly
value={props.left}
extensions={[shopify.jsonc()]}
/>
<Merge.Modified
readOnly
value={props.right}
extensions={[shopify.jsonc()]}
/>
</Merge>
)}
</ClientOnly>
</ErrorBoundary>
)}
</div>
</div>
)
}
-125
View File
@@ -1,125 +0,0 @@
import { json } from '@codemirror/lang-json'
import { yaml } from '@codemirror/lang-yaml'
import { useFetcher } from '@remix-run/react'
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
import CodeMirror from '@uiw/react-codemirror'
import clsx from 'clsx'
import { useEffect, useMemo, useState } from 'react'
import CodeMirrorMerge from 'react-codemirror-merge'
import Button from '~/components/Button'
import Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import Fallback from './fallback'
interface EditorProperties {
readonly acl: string
readonly setAcl: (acl: string) => void
readonly mode: 'edit' | 'diff'
readonly data: {
hasAclWrite: boolean
currentAcl: string
aclType: string
}
}
export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
const [light, setLight] = useState(false)
const [loading, setLoading] = useState(true)
const fetcher = useFetcher()
const aclType = useMemo(() => data.aclType === 'json' ? json() : yaml(), [data.aclType])
useEffect(() => {
const theme = window.matchMedia('(prefers-color-scheme: light)')
setLight(theme.matches)
theme.addEventListener('change', (theme) => {
setLight(theme.matches)
})
// Prevents the FOUC
setLoading(false)
}, [])
return (
<>
<div className={clsx(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden',
)}
>
<div className="overflow-y-scroll h-editor text-sm">
{loading
? (
<Fallback acl={acl} where="client" />
)
: (
mode === 'edit'
? (
<CodeMirror
value={acl}
theme={light ? githubLight : githubDark}
extensions={[aclType]}
readOnly={!data.hasAclWrite}
onChange={(value) => {
setAcl(value)
}}
/>
)
: (
<CodeMirrorMerge
theme={light ? githubLight : githubDark}
orientation="a-b"
>
<CodeMirrorMerge.Original
readOnly
value={data.currentAcl}
extensions={[aclType]}
/>
<CodeMirrorMerge.Modified
readOnly
value={acl}
extensions={[aclType]}
/>
</CodeMirrorMerge>
)
)}
</div>
</div>
<Button
variant="heavy"
className="mr-2"
isDisabled={fetcher.state === 'loading' || !data.hasAclWrite || data.currentAcl === acl}
onPress={() => {
fetcher.submit({
acl,
}, {
method: 'PATCH',
encType: 'application/json',
})
toast('Updated tailnet ACL policy')
}}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Save
</Button>
<Button
isDisabled={fetcher.state === 'loading' || data.currentAcl === acl}
onPress={() => {
setAcl(data.currentAcl)
}}
>
Discard Changes
</Button>
</>
)
}
+30
View File
@@ -0,0 +1,30 @@
import { cn } from '~/utils/cn'
import { AlertIcon } from '@primer/octicons-react'
import Card from '~/components/Card'
import Code from '~/components/Code'
interface Props {
message: string
}
export function ErrorView({ message }: Props) {
return (
<Card variant="flat" className="max-w-full mb-4">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">
Error
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500"/>
</div>
<Card.Text className="mt-4">
Could not apply changes to your ACL policy
due to the following error:
<br />
<Code>
{message}
</Code>
</Card.Text>
</Card>
)
}
+18 -37
View File
@@ -1,43 +1,24 @@
import clsx from 'clsx'
import Spinner from '~/components/Spinner'
import { cn } from '~/utils/cn'
import Button from '~/components/Button'
type FallbackProperties = {
readonly acl: string;
readonly where: 'client' | 'server';
interface Props {
readonly acl: string
}
export default function Fallback({ acl, where }: FallbackProperties) {
export default function Fallback({ acl }: Props) {
return (
<>
<div className={clsx(
where === 'server' ? 'mb-2 overflow-hidden rounded-tr-lg rounded-b-lg' : '',
where === 'server' ? 'border border-gray-200 dark:border-gray-700' : ''
)}
>
<textarea
readOnly
className={clsx(
'w-full h-editor font-mono resize-none',
'text-sm text-gray-600 dark:text-gray-300',
'pl-10 pt-1 leading-snug'
)}
value={acl}
/>
</div>
{where === 'server' ? (
<>
<Button
variant='heavy'
className='mr-2'
>
Save
</Button>
<Button>
Discard Changes
</Button>
</>
) : undefined}
</>
<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-10 pt-1 leading-snug',
)}
value={acl}
/>
</div>
)
}
+288 -108
View File
@@ -1,75 +1,215 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { useState } from 'react'
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData, useRevalidator } from '@remix-run/react'
import { useDebounceFetcher } from 'remix-utils/use-debounce-fetcher'
import { useEffect, useState, useMemo } from 'react'
import { Tab, TabList, TabPanel, Tabs } from 'react-aria-components'
import { ClientOnly } from 'remix-utils/client-only'
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 { loadAcl, loadContext, patchAcl } from '~/utils/config/headplane'
import { sighupHeadscale } from '~/utils/docker'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
import { HeadscaleError, pull, put } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import log from '~/utils/log'
import Editor from './editor'
import Fallback from './fallback'
import { Editor, Differ } from './cm.client'
import { Unavailable } from './unavailable'
import { ErrorView } from './error'
export async function loader() {
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
// The way policy is handled in 0.23 of Headscale and later is verbose.
// The 2 ACL policy modes are either the database one or file one
//
// File: The ACL policy is readonly to the API and manually edited
// Database: The ACL policy is read/write to the API
//
// To determine if we first have an ACL policy available we need to check
// if fetching the v1/policy route gives us a 500 status code or a 200.
//
// 500 can mean many different things here unfortunately:
// - In file based that means the file is not accessible
// - In database mode this can mean that we have never set an ACL policy
// - In database mode this can mean that the ACL policy is not available
// - A general server error may have occurred
//
// Unfortunately the server errors are not very descriptive so we have to
// do some silly guesswork here. If we are running in an integration mode
// and have the Headscale configuration available to us, our assumptions
// can be more accurate, otherwise we just HAVE to assume that the ACL
// policy has never been set.
//
// We can do damage control by checking for write access and if we are not
// able to PUT an ACL policy on the v1/policy route, we can already know
// that the policy is at the very-least readonly or not available.
const context = await loadContext()
if (!context.acl.read) {
throw new Error('No ACL configuration is available')
let modeGuess = 'database' // Assume database mode
if (context.config.read) {
const config = await loadConfig()
modeGuess = config.policy?.mode ?? 'database'
}
const { data, type } = await loadAcl()
return {
hasAclWrite: context.acl.write,
currentAcl: data,
aclType: type,
// Attempt to load the policy, for both the frontend and for checking
// if we are able to write to the policy for write access
try {
const { policy } = await pull<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
)
let write = false // On file mode we already know it's readonly
if (modeGuess === 'database' && policy.length > 0) {
try {
await put('v1/policy', session.get('hsApiKey')!, {
policy: policy,
})
write = true
} catch (error) {
write = false
log.debug(
'APIC',
'Failed to write to ACL policy with error %s',
error
)
}
}
return {
read: true,
write,
mode: modeGuess,
policy
}
} catch {
// If we are explicit on file mode then this is the end of the road
if (modeGuess === 'file') {
return {
read: false,
write: false,
mode: modeGuess,
policy: null
}
}
// Assume that we have write access otherwise?
// This is sort of a brittle assumption to make but we don't want
// to create a default policy if we don't have to.
return {
read: true,
write: true,
mode: modeGuess,
policy: null
}
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ success: false }, {
return json({ success: false, error: null }, {
status: 401,
})
}
const context = await loadContext()
if (!context.acl.write) {
return json({ success: false }, {
status: 403,
try {
const { acl } = await request.json() as { acl: string }
const { policy } = await put<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
{
policy: acl,
}
)
return json({ success: true, policy, error: null })
} catch (error) {
log.debug('APIC', 'Failed to update ACL policy with error %s', error)
// @ts-ignore: Shut UP we know it's a string most of the time
const text = JSON.parse(error.message)
return json({ success: false, error: text.message }, {
status: error instanceof HeadscaleError ? error.status : 500,
})
}
const data = await request.json() as { acl: string }
await patchAcl(data.acl)
if (context.docker) {
await sighupHeadscale()
}
return json({ success: true })
return json({ success: true, error: null })
}
export default function Page() {
const data = useLoaderData<typeof loader>()
const [acl, setAcl] = useState(data.currentAcl)
const fetcher = useDebounceFetcher<typeof action>()
const revalidator = useRevalidator()
const [acl, setAcl] = useState(data.policy ?? '')
const [toasted, setToasted] = useState(false)
useEffect(() => {
if (!fetcher.data || toasted) {
return
}
// @ts-ignore: useDebounceFetcher is not typed correctly
if (fetcher.data.success) {
toast('Updated tailnet ACL policy')
} else {
toast('Failed to update tailnet ACL policy')
}
setToasted(true)
if (revalidator.state === 'idle') {
revalidator.revalidate()
}
}, [fetcher.data, toasted, data.policy])
// The state for if the save and discard buttons should be disabled
// is pretty complicated to calculate and varies on different states.
const disabled = useMemo(() => {
if (!data.read || !data.write) {
return true
}
// First check our fetcher states
if (fetcher.state === 'loading') {
return true
}
if (revalidator.state === 'loading') {
return true
}
// If we have a failed fetcher state allow the user to try again
// @ts-ignore: useDebounceFetcher is not typed correctly
if (fetcher.data?.success === false) {
return false
}
return data.policy === acl
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl])
return (
<div>
{data.hasAclWrite
? undefined
: (
{data.read && !data.write
? (
<div className="mb-4">
<Notice>
The ACL policy file is readonly to Headplane.
You will not be able to make changes here.
<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>
)}
) : undefined}
<h1 className="text-2xl font-medium mb-4">
Access Control List (ACL)
@@ -97,80 +237,120 @@ export default function Page() {
.
</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' : '',
{
// @ts-ignore: useDebounceFetcher is not typed correctly
fetcher.data?.success === false
? (
// @ts-ignore: useDebounceFetcher is not typed correctly
<ErrorView message={fetcher.data.error} />
) : undefined}
{data.read ? (
<>
<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">
<Editor
isDisabled={!data.write}
value={acl}
onChange={setAcl}
/>
</TabPanel>
<TabPanel id="diff">
<Differ
left={data?.policy ?? ''}
right={acl}
/>
</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={disabled}
onPress={() => {
setToasted(false)
fetcher.submit({
acl,
}, {
method: 'PATCH',
encType: 'application/json',
})
}}
>
<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' : '',
)}
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Save
</Button>
<Button
isDisabled={disabled}
onPress={() => {
setAcl(data?.policy ?? '')
}}
>
<EyeIcon className="w-5 h-5" />
<p>Preview changes</p>
</Tab>
<Tab
id="preview"
className={({ isSelected }) => cn(
'px-4 py-2 rounded-tr-lg',
'focus:outline-none flex items-center gap-2',
'border-x border-gray-200 dark:border-gray-700',
isSelected ? 'text-gray-900 dark:text-gray-100' : '',
)}
>
<BeakerIcon className="w-5 h-5" />
<p>Preview rules</p>
</Tab>
</TabList>
<TabPanel id="edit">
<ClientOnly fallback={<Fallback acl={acl} where="server" />}>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode="edit" />
)}
</ClientOnly>
</TabPanel>
<TabPanel id="diff">
<ClientOnly fallback={<Fallback acl={acl} where="server" />}>
{() => (
<Editor data={data} acl={acl} setAcl={setAcl} mode="diff" />
)}
</ClientOnly>
</TabPanel>
<TabPanel id="preview">
<div
className={cn(
'border border-gray-200 dark:border-gray-700',
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
'p-16 flex flex-col items-center justify-center',
)}
>
<IssueDraftIcon className="w-24 h-24 text-gray-300 dark:text-gray-500" />
<p className="w-1/2 text-center mt-4">
The Preview rules is very much still a work in progress.
It is a bit complicated to implement right now but hopefully it will be available soon.
</p>
</div>
</TabPanel>
</Tabs>
Discard Changes
</Button>
</>
) : <Unavailable mode={data.mode as "database" | "file"} />}
</div>
)
}
@@ -0,0 +1,43 @@
import { cn } from '~/utils/cn'
import { AlertIcon } from '@primer/octicons-react'
import Code from '~/components/Code'
import Card from '~/components/Card'
interface Props {
mode: 'file' | 'database'
}
export function Unavailable({ mode }: Props) {
return (
<Card variant="flat" className="max-w-prose mt-12">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">
ACL Policy Unavailable
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500"/>
</div>
<Card.Text className="mt-4">
Unable to load a valid ACL policy configuration.
This is most likely due to a misconfiguration in your
Headscale configuration file.
</Card.Text>
{mode !== 'file' ? (
<p className="mt-4 text-sm">
According to your configuration, the ACL policy mode
is set to <Code>file</Code> but the ACL file is not
available. Ensure that the <Code>policy.path</Code> is
set to a valid path in your Headscale configuration.
</p>
) : (
<p className="mt-4 text-sm">
In order to fully utilize the ACL management features of
Headplane, please set <Code>policy.mode</Code> to either
{' '}<Code>file</Code> or <Code>database</Code> in your
Headscale configuration.
</p>
)}
</Card>
)
}
+121
View File
@@ -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>
)
}
@@ -55,7 +55,7 @@ export default function AddNameserver({ nameservers }: Props) {
}
submit({
'dns_config.restricted_nameservers': splitNs,
'dns.nameservers.split': splitNs,
}, {
method: 'PATCH',
encType: 'application/json',
@@ -65,7 +65,7 @@ export default function AddNameserver({ nameservers }: Props) {
globalNs.push(ns)
submit({
'dns_config.nameservers': globalNs,
'dns.nameservers.global': globalNs,
}, {
method: 'PATCH',
encType: 'application/json',
+88
View File
@@ -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>
)
}
+2 -3
View File
@@ -134,7 +134,7 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': [...localDomains, newDomain]
'dns.search_domains': [...localDomains, newDomain]
}, {
method: 'PATCH',
encType: 'application/json'
@@ -212,8 +212,7 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
isDisabled={disabled}
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.domains': localDomains.filter((_, index) => index !== id - 1)
'dns.search_domains': localDomains.filter((_, index) => index !== id - 1)
}, {
method: 'PATCH',
encType: 'application/json'
+1 -1
View File
@@ -42,7 +42,7 @@ export default function Modal({ isEnabled, disabled }: Properties) {
onPress={() => {
fetcher.submit({
// eslint-disable-next-line @typescript-eslint/naming-convention
'dns_config.magic_dns': !isEnabled
'dns.magic_dns': !isEnabled
}, {
method: 'PATCH',
encType: 'application/json'
+14 -37
View File
@@ -11,11 +11,10 @@ import AddNameserver from './dialogs/nameserver'
interface Props {
nameservers: Record<string, string[]>
override: boolean
isDisabled: boolean
}
export default function Nameservers({ nameservers, override, isDisabled }: Props) {
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>
@@ -36,8 +35,7 @@ export default function Nameservers({ nameservers, override, isDisabled }: Props
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers[key]}
override={override}
nameservers={nameservers}
name={key}
/>
))}
@@ -57,12 +55,14 @@ interface ListProps {
isDisabled: boolean
nameservers: string[]
name: string
override: boolean
}
function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: ListProps) {
const [localOverride, setLocalOverride] = useState(override)
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
const submit = useSubmit()
const list = isGlobal ? nameservers['global'] : nameservers[name]
if (list.length === 0) {
return null
}
return (
<div className="mb-8">
@@ -70,33 +70,9 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: L
<h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name}
</h2>
{isGlobal
? (
<div className="flex gap-2 items-center">
<span className="text-sm opacity-50">
Override local DNS
</span>
<Switch
label="Override local DNS"
defaultSelected={localOverride}
isDisabled={isDisabled}
onChange={() => {
submit({
'dns_config.override_local_dns': !localOverride,
}, {
method: 'PATCH',
encType: 'application/json',
})
setLocalOverride(!localOverride)
}}
/>
</div>
)
: undefined}
</div>
<TableList>
{nameservers.map((ns, index) => (
{list.length > 0 ? list.map((ns, index) => (
// eslint-disable-next-line react/no-array-index-key
<TableList.Item key={index}>
<p className="font-mono text-sm">{ns}</p>
@@ -111,17 +87,18 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: L
onPress={() => {
if (isGlobal) {
submit({
'dns_config.nameservers': nameservers
'dns.nameservers.global': list
.filter((_, i) => i !== index),
}, {
method: 'PATCH',
encType: 'application/json',
})
} else {
const key = `dns_config.restricted_nameservers."${name}"`
submit({
[key]: nameservers
.filter((_, i) => i !== index),
'dns.nameservers.split': {
...nameservers,
[name]: list.filter((_, i) => i !== index),
}
}, {
method: 'PATCH',
encType: 'application/json',
@@ -132,7 +109,7 @@ function NameserverList({ isGlobal, isDisabled, nameservers, name, override }: L
Remove
</Button>
</TableList.Item>
))}
)) : undefined}
</TableList>
</div>
)
+2 -2
View File
@@ -28,7 +28,7 @@ export default function Modal({ name, disabled }: Properties) {
Devices are accessible at
{' '}
<Code>
[device].[user].{name}
[device].{name}
</Code>
{' '}
when Magic DNS is enabled.
@@ -80,7 +80,7 @@ export default function Modal({ name, disabled }: Properties) {
variant='confirm'
onPress={() => {
fetcher.submit({
'dns_config.base_domain': newName
'dns.base_domain': newName
}, {
method: 'PATCH',
encType: 'application/json'
+20 -11
View File
@@ -5,10 +5,10 @@ import Code from '~/components/Code'
import Notice from '~/components/Notice'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig, patchConfig } from '~/utils/config/headscale'
import { restartHeadscale } from '~/utils/docker'
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'
@@ -24,13 +24,14 @@ export async function loader() {
const config = await loadConfig()
const dns = {
prefixes: config.prefixes,
magicDns: config.dns_config.magic_dns,
baseDomain: config.dns_config.base_domain,
overrideLocal: config.dns_config.override_local_dns,
nameservers: config.dns_config.nameservers,
splitDns: config.dns_config.restricted_nameservers,
searchDomains: config.dns_config.domains,
extraRecords: config.dns_config.extra_records,
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 {
@@ -56,7 +57,11 @@ export async function action({ request }: ActionFunctionArgs) {
const data = await request.json() as Record<string, unknown>
await patchConfig(data)
await restartHeadscale()
if (context.integration?.onConfigChange) {
await context.integration.onConfigChange(context.integration.context)
}
return json({ success: true })
}
@@ -83,7 +88,11 @@ export default function Page() {
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
<Nameservers
nameservers={allNs}
override={data.overrideLocal}
isDisabled={!data.config.write}
/>
<DNS
records={data.extraRecords}
isDisabled={!data.config.write}
/>
@@ -100,7 +109,7 @@ export default function Page() {
on the tailnet. Devices will be accessible at
{' '}
<Code>
[device].[user].
[device].
{data.baseDomain}
</Code>
{' '}
-75
View File
@@ -1,75 +0,0 @@
import { type LoaderFunctionArgs } from '@remix-run/node'
import { Link, useLoaderData } from '@remix-run/react'
import Attribute from '~/components/Attribute'
import Card from '~/components/Card'
import StatusCircle from '~/components/StatusCircle'
import { type Machine } from '~/types'
import { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'))
if (!params.id) {
throw new Error('No machine ID provided')
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const data = await pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!)
return data.node
}
export default function Page() {
const data = useLoaderData<typeof loader>()
useLiveData({ interval: 1000 })
return (
<div>
<p className='mb-4 text-gray-500 dark:text-gray-400 text-sm'>
<Link
to='/machines'
className='font-bold text-gray-700 dark:text-gray-300 hover:underline'
>
All Machines
</Link>
{' / '}
{data.givenName}
</p>
<span className='flex items-baseline gap-x-4 text-sm mb-4'>
<h1 className='text-2xl font-bold'>
{data.givenName}
</h1>
<StatusCircle isOnline={data.online} className='w-4 h-4'/>
</span>
<Card variant='flat'>
<Attribute name='Creator' value={data.user.name}/>
<Attribute name='Node ID' value={data.id}/>
<Attribute name='Node Name' value={data.givenName}/>
<Attribute name='Hostname' value={data.name}/>
<Attribute
isCopyable
name='Node Key'
value={data.nodeKey}
/>
<Attribute
name='Created'
value={new Date(data.createdAt).toLocaleString()}
/>
<Attribute
name='Last Seen'
value={new Date(data.lastSeen).toLocaleString()}
/>
<Attribute
name='Expiry'
value={new Date(data.expiry).toLocaleString()}
/>
<Attribute
isCopyable
name='Domain'
value={`${data.givenName}.${data.user.name}.ts.net`}
/>
</Card>
</div>
)
}
+396
View File
@@ -0,0 +1,396 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { Link as RemixLink, useLoaderData } from '@remix-run/react'
import { InfoIcon, GearIcon, CheckCircleIcon, SkipIcon, PersonIcon } from '@primer/octicons-react'
import { useMemo, useState } from 'react'
import Attribute from '~/components/Attribute'
import Button from '~/components/Button'
import Card from '~/components/Card'
import Menu from '~/components/Menu'
import Tooltip from '~/components/Tooltip'
import StatusCircle from '~/components/StatusCircle'
import { 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 Link from '~/components/Link'
import { menuAction } from '../_data.machines._index/action'
import MenuOptions from '../_data.machines._index/menu'
import Routes from '../_data.machines._index/dialogs/routes'
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>()
const routesState = useState(false)
useLiveData({ interval: 1000 })
const expired = machine.expiry === '0001-01-01 00:00:00'
|| machine.expiry === '0001-01-01T00:00:00Z'
|| machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now()
const tags = [
...machine.forcedTags,
...machine.validTags,
]
if (expired) {
tags.unshift('Expired')
}
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route)
return acc
}
if (route.enabled) {
acc.subnetApproved.push(route)
return acc
}
acc.subnet.push(route)
return acc
}, { exit: [], subnetApproved: [], subnet: [] })
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false
return exit[0].enabled && exit[1].enabled
}, [exit])
if (exitEnabled) {
tags.unshift('Exit Node')
}
if (subnetApproved.length > 0) {
tags.unshift('Subnets')
}
return (
<div>
<p className="mb-8 text-md">
<RemixLink
to="/machines"
className="font-medium"
>
All Machines
</RemixLink>
<span className="mx-2">
/
</span>
{machine.givenName}
</p>
<div className={cn(
'flex justify-between items-center',
'border-b border-ui-100 dark:border-ui-800',
)}>
<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
className={cn(
'bg-ui-100 dark:bg-ui-800',
)}
machine={machine}
routes={routes}
users={users}
magic={magic}
buttonChild={
<Menu.Button className={cn(
'flex items-center justify-center gap-x-2',
'bg-main-200 dark:bg-main-700/30',
'hover:bg-main-300 dark:hover:bg-main-600/30',
'text-ui-700 dark:text-ui-300 mb-2',
'w-fit text-sm rounded-lg px-3 py-2'
)}>
<GearIcon className="w-5" />
Machine Settings
</Menu.Button>
}
/>
</div>
<div className="flex gap-1 mb-4">
<div className="border-r border-ui-100 dark:border-ui-800 p-2 pr-4">
<span className="text-sm text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Managed by
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
By default, a machines permissions match its creators.
</Tooltip.Body>
</Tooltip>
</span>
<div className="flex items-center gap-x-2.5 mt-1">
<div className={cn(
'rounded-full h-7 w-7 flex items-center justify-center',
'border border-ui-200 dark:border-ui-700',
)}>
<PersonIcon className="w-4 h-4" />
</div>
{machine.user.name}
</div>
</div>
<div className="p-2 pl-4">
<p className="text-sm text-ui-600 dark:text-ui-300">
Status
</p>
<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>
</div>
</div>
<h2 className="text-xl font-medium mb-4 mt-8">
Subnets & Routing
</h2>
<Routes
machine={machine}
routes={routes}
state={routesState}
/>
<div className="flex items-center justify-between mb-4">
<p>
Subnets let you expose physical network routes onto Tailscale.
{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
>
Learn More
</Link>
</p>
<Button
variant="light"
control={routesState}
>
Review
</Button>
</div>
<Card
variant="flat"
className={cn(
'w-full max-w-full grid sm:grid-cols-2',
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
)}
>
<div>
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Approved
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
Traffic to these routes are being
routed through this machine.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{subnetApproved.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300">
</span>
) : (
<ul className="leading-normal">
{subnetApproved.map(route => (
<li key={route.id}>
{route.prefix}
</li>
))}
</ul>
)}
</div>
<Button
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'text-blue-500 dark:text-blue-400',
'hover:bg-transparent',
'hover:text-blue-600 dark:hover:text-blue-500',
)}
control={routesState}
>
Edit
</Button>
</div>
<div>
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Awaiting Approval
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
This machine is advertising these routes,
but they must be approved before traffic
will be routed to them.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{subnet.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300">
</span>
) : (
<ul className="leading-normal">
{subnet.map(route => (
<li key={route.id}>
{route.prefix}
</li>
))}
</ul>
)}
</div>
<Button
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'text-blue-500 dark:text-blue-400',
'hover:bg-transparent',
'hover:text-blue-600 dark:hover:text-blue-500',
)}
control={routesState}
>
Edit
</Button>
</div>
<div>
<span className="text-ui-600 dark:text-ui-300 flex items-center gap-x-1">
Exit Node
<Tooltip>
<Tooltip.Button>
<InfoIcon className="w-3.5 h-3.5" />
</Tooltip.Button>
<Tooltip.Body>
Whether this machine can act as an
exit node for your tailnet.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{exit.length === 0 ? (
<span className="text-ui-400 dark:text-ui-300">
</span>
) : exitEnabled ? (
<span className="flex items-center gap-x-1">
<CheckCircleIcon className="w-3.5 h-3.5 text-green-700" />
Allowed
</span>
) : (
<span className="flex items-center gap-x-1">
<SkipIcon className="w-3.5 h-3.5 text-red-700" />
Awaiting Approval
</span>
)}
</div>
<Button
className={cn(
'p-0 rounded-sm bg-transparent mt-1',
'text-blue-500 dark:text-blue-400',
'hover:bg-transparent',
'hover:text-blue-600 dark:hover:text-blue-500',
)}
control={routesState}
>
Edit
</Button>
</div>
</Card>
<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={expired
? new Date(machine.expiry).toLocaleString()
: 'Never'
}
/>
{magic
? (
<Attribute
isCopyable
name="Domain"
value={`${machine.givenName}.${magic}`}
/>
)
: undefined}
</Card>
</div>
)
}
+168
View File
@@ -0,0 +1,168 @@
/* 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'
import log from '~/utils/log'
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 'exit-node': {
if (!data.has('routes') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, {
status: 400,
})
}
const routes = data.get('routes')?.toString().split(',') ?? []
const enabled = data.get('enabled') === 'true'
const postfix = enabled ? 'enable' : 'disable'
await Promise.all(routes.map(async (route) => {
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
}))
return json({ message: 'Exit node 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(',')
.filter((tag) => tag.trim() !== '')
?? []
try {
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
tags,
})
return json({ message: 'Tags updated' })
} catch (error) {
log.debug('APIC', 'Failed to update tags: %s', error)
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 {
const qp = new URLSearchParams()
qp.append('user', user)
qp.append('key', key)
const url = `v1/node/register?${qp.toString()}`
await post(url, session.get('hsApiKey')!, {
user, key,
})
return json({
success: true,
message: 'Machine registered'
})
} catch {
return json({
success: false,
message: 'Failed to register machine'
}, {
status: 500,
})
}
}
default: {
return json({ message: 'Invalid method' }, {
status: 400,
})
}
}
}
@@ -1,54 +1,62 @@
import { type FetcherWithComponents } from '@remix-run/react'
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'
type DeleteProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
interface DeleteProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
}
export default function Delete({ machine, fetcher, state }: DeleteProperties) {
export default function Delete({ machine, state }: DeleteProps) {
const submit = useSubmit()
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Remove {machine.givenName}
Remove
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from
your network. To re-add it, you will need to
reauthenticate to your tailnet from the device.
</Dialog.Text>
<fetcher.Form method='POST'>
<input type='hidden' name='_method' value='delete'/>
<input type='hidden' name='id' value={machine.id}/>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<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'
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
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'
'text-white dark:text-white',
)}
onPress={close}
>
Remove
</Dialog.Action>
</div>
</fetcher.Form>
</Form>
</>
)}
</Dialog.Panel>
@@ -1,54 +1,62 @@
import { type FetcherWithComponents } from '@remix-run/react'
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'
type DeleteProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
interface ExpireProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
}
export default function Expire({ machine, fetcher, state }: DeleteProperties) {
export default function Expire({ machine, state }: ExpireProps) {
const submit = useSubmit()
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Expire {machine.givenName}
Expire
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
This will disconnect the machine from your Tailnet.
In order to reconnect, you will need to re-authenticate
from the device.
</Dialog.Text>
<fetcher.Form method='POST'>
<input type='hidden' name='_method' value='expire'/>
<input type='hidden' name='id' value={machine.id}/>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<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'
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
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'
'text-white dark:text-white',
)}
onPress={close}
>
Expire
</Dialog.Action>
</div>
</fetcher.Form>
</Form>
</>
)}
</Dialog.Panel>
@@ -0,0 +1,87 @@
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.
</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
? (
<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}
.
{magic}
</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}
>
Change owner
</Dialog.Action>
</div>
</Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -0,0 +1,134 @@
import { Form, useFetcher, Link } from '@remix-run/react'
import { Dispatch, SetStateAction, useState, useEffect } 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 Spinner from '~/components/Spinner'
import { toast } from '~/components/Toaster'
import { Machine, User } from '~/types'
export interface NewProps {
server: string
users: User[]
}
export default function New(data: NewProps) {
const fetcher = useFetcher<{ success?: boolean }>()
const mkeyState = useState(false)
const [mkey, setMkey] = useState('')
const [user, setUser] = useState('')
const [toasted, setToasted] = useState(false)
useEffect(() => {
if (!fetcher.data || toasted) {
return
}
if (fetcher.data.success) {
toast('Registered new machine')
} else {
toast('Failed to register machine due to an invalid key')
}
setToasted(true)
}, [fetcher.data, toasted, mkey])
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 isCopyable>
tailscale up --login-server=
{data.server}
</Code>
{' '}
on your device.
</Dialog.Text>
<fetcher.Form method="POST" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<TextField
label='Machine Key'
placeholder='mkey: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"
isDisabled={!mkey || !mkey.trim().startsWith('mkey:') || !user}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Register
</Dialog.Action>
</div>
</fetcher.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>
<Link to="/settings/auth-keys">
<KeyIcon className='w-4 h-4 mr-2'/>
Generate Pre-auth Key
</Link>
</Menu.ItemButton>
</Menu.Items>
</Menu>
</>
)
}
@@ -1,4 +1,4 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { Form, useSubmit } from '@remix-run/react'
import { type Dispatch, type SetStateAction, useState } from 'react'
import Code from '~/components/Code'
@@ -6,15 +6,15 @@ import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import { type Machine } from '~/types'
type RenameProperties = {
readonly machine: Machine;
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
readonly magic?: string;
interface RenameProps {
readonly machine: Machine
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
readonly magic?: string
}
export default function Rename({ machine, fetcher, state, magic }: RenameProperties) {
export default function Rename({ machine, state, magic }: RenameProps) {
const [name, setName] = useState(machine.givenName)
const submit = useSubmit()
return (
<Dialog>
@@ -22,65 +22,76 @@ export default function Rename({ machine, fetcher, state, magic }: RenamePropert
{close => (
<>
<Dialog.Title>
Edit machine name for {machine.givenName}
Edit machine name for
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text>
This name is shown in the admin panel, in Tailscale clients,
and used when generating MagicDNS names.
</Dialog.Text>
<fetcher.Form method='POST'>
<input type='hidden' name='_method' value='rename'/>
<input type='hidden' name='id' value={machine.id}/>
<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'
label="Machine name"
placeholder="Machine name"
name="name"
state={[name, setName]}
className='my-2'
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'>
{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'
variant="cancel"
onPress={close}
>
Cancel
</Dialog.Action>
<Dialog.Action
variant='confirm'
variant="confirm"
onPress={close}
>
Rename
</Dialog.Action>
</div>
</fetcher.Form>
</Form>
</>
)}
</Dialog.Panel>
@@ -1,57 +1,89 @@
import { type FetcherWithComponents } from '@remix-run/react'
import { type Dispatch, type SetStateAction } from 'react'
import { useFetcher } from '@remix-run/react'
import { Dispatch, SetStateAction, useMemo } from 'react'
import Dialog from '~/components/Dialog'
import Switch from '~/components/Switch'
import { type Machine, type Route } from '~/types'
import Link from '~/components/Link'
import { Machine, Route } from '~/types'
import { cn } from '~/utils/cn'
type RoutesProperties = {
readonly machine: Machine;
readonly routes: Route[];
readonly fetcher: FetcherWithComponents<unknown>;
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
interface RoutesProps {
readonly machine: Machine
readonly routes: Route[]
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
}
// TODO: Support deleting routes
export default function Routes({ machine, routes, fetcher, state }: RoutesProperties) {
export default function Routes({ machine, routes, state }: RoutesProps) {
const fetcher = useFetcher()
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet } = routes.reduce((acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route)
return acc
}
acc.subnet.push(route)
return acc
}, { exit: [], subnet: [] })
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false
return exit[0].enabled && exit[1].enabled
}, [exit])
return (
<Dialog>
<Dialog.Panel control={state}>
{close => (
<>
<Dialog.Title>
Edit route settings of {machine.givenName}
Edit route settings of
{' '}
{machine.givenName}
</Dialog.Title>
<Dialog.Text className="font-bold">
Subnet routes
</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on
by advertising IP ranges as subnet routes.
{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
>
Learn More
</Link>
</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'
'border border-zinc-200 dark:border-zinc-700',
)}
>
{routes.length === 0 ? (
{subnet.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}
{subnet.map(route => (
<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}
key={route.id}
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between'
'items-center justify-between',
)}
>
<p>
@@ -59,8 +91,8 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
</p>
<Switch
defaultSelected={route.enabled}
label='Enabled'
onChange={checked => {
label="Enabled"
onChange={(checked) => {
const form = new FormData()
form.set('id', machine.id)
form.set('_method', 'routes')
@@ -68,16 +100,76 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
form.set('enabled', String(checked))
fetcher.submit(form, {
method: 'POST'
method: 'POST',
})
}}
/>
</div>
))}
</div>
<div className='mt-6 flex justify-end gap-2 mt-6'>
<Dialog.Text className="font-bold mt-8">
Exit nodes
</Dialog.Text>
<Dialog.Text>
Allow your network to route internet traffic through this machine.
{' '}
<Link
to="https://tailscale.com/kb/1103/exit-nodes"
name="Tailscale Exit-node Documentation"
>
Learn More
</Link>
</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',
)}
>
{exit.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>
This machine is not an exit node.
</p>
</div>
) : (
<div
className={cn(
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
'items-center justify-between',
)}
>
<p>
Use as exit node
</p>
<Switch
defaultSelected={exitEnabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData()
form.set('id', machine.id)
form.set('_method', 'exit-node')
form.set('routes', exit.map(route => route.id).join(','))
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'
variant="cancel"
isDisabled={fetcher.state === 'submitting'}
onPress={close}
>
@@ -0,0 +1,161 @@
import { PlusIcon, XIcon } from '@primer/octicons-react'
import { Form, useSubmit } from '@remix-run/react'
import { Dispatch, SetStateAction, useState } from 'react'
import { Button, Input } from 'react-aria-components'
import Dialog from '~/components/Dialog'
import Link from '~/components/Link'
import { 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>
)
}
+93 -113
View File
@@ -1,71 +1,99 @@
/* eslint-disable react/hook-use-state */
import { ChevronDownIcon, CopyIcon, KebabHorizontalIcon } from '@primer/octicons-react'
import { type FetcherWithComponents, Link } from '@remix-run/react'
import { useState } from 'react'
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react'
import { Link } from '@remix-run/react'
import Menu from '~/components/Menu'
import StatusCircle from '~/components/StatusCircle'
import { toast } from '~/components/Toaster'
import { type Machine, type Route } from '~/types'
import { Machine, Route, User } from '~/types'
import { cn } from '~/utils/cn'
import Delete from './dialogs/delete'
import Expire from './dialogs/expire'
import Rename from './dialogs/rename'
import Routes from './dialogs/routes'
import MenuOptions from './menu'
type MachineProperties = {
readonly machine: Machine;
readonly routes: Route[];
readonly fetcher: FetcherWithComponents<unknown>;
readonly magic?: string;
interface Props {
readonly machine: Machine
readonly routes: Route[]
readonly users: User[]
readonly magic?: string
}
export default function MachineRow({ machine, routes, fetcher, magic }: MachineProperties) {
const renameState = useState(false)
const expireState = useState(false)
const removeState = useState(false)
const routesState = useState(false)
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'
|| machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now()
const expired = new Date(machine.expiry).getTime() < Date.now()
const tags = [
...machine.forcedTags,
...machine.validTags
...machine.validTags,
]
if (expired) {
tags.unshift('Expired')
}
let prefix = magic?.startsWith('[user]')
? magic.replace('[user]', machine.user.name)
: magic
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce((acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route)
return acc
}
if (route.enabled) {
acc.subnetApproved.push(route)
return acc
}
acc.subnet.push(route)
return acc
}, { exit: [], subnetApproved: [], subnet: [] })
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false
return exit[0].enabled && exit[1].enabled
}, [exit])
if (exitEnabled) {
tags.unshift('Exit Node')
}
if (subnetApproved.length > 0) {
tags.unshift('Subnets')
}
return (
<tr
key={machine.id}
className='hover:bg-zinc-100 dark:hover:bg-zinc-800 group'
className="hover:bg-zinc-100 dark:hover:bg-zinc-800 group"
>
<td className='pl-0.5 py-2'>
<td className="pl-0.5 py-2">
<Link
to={`/machines/${machine.id}`}
className='group/link h-full'
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'
'group-hover/link:dark:text-blue-400',
)}
>
{machine.givenName}
</p>
<p className='text-sm text-gray-500 dark:text-gray-300 font-mono'>
<p className="text-sm text-gray-500 dark:text-gray-300 font-mono">
{machine.name}
</p>
<div className='flex gap-1 mt-1'>
<div className="flex gap-1 mt-1">
{tags.map(tag => (
<span
key={tag}
className={cn(
'text-xs rounded-sm px-1 py-0.5',
'bg-gray-100 dark:bg-zinc-700',
'text-gray-600 dark:text-gray-300'
'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}
@@ -74,21 +102,21 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
</div>
</Link>
</td>
<td className='py-2'>
<div className='flex items-center gap-x-1'>
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
<Menu>
<Menu.Button>
<ChevronDownIcon className='w-4 h-4'/>
<ChevronDownIcon className="w-4 h-4" />
</Menu.Button>
<Menu.Items>
{machine.ipAddresses.map(ip => (
<Menu.ItemButton
key={ip}
type='button'
type="button"
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full'
'justify-between w-full',
)}
onPress={async () => {
await navigator.clipboard.writeText(ip)
@@ -96,108 +124,60 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
}}
>
{ip}
<CopyIcon className='w-3 h-3'/>
<CopyIcon className="w-3 h-3" />
</Menu.ItemButton>
))}
{magic ? (
<Menu.ItemButton
type='button'
className={cn(
'flex items-center gap-x-1.5 text-sm',
'justify-between w-full break-keep'
)}
onPress={async () => {
const ip = `${machine.givenName}.${machine.user.name}.${magic}`
await navigator.clipboard.writeText(ip)
toast('Copied hostname to clipboard')
}}
>
{machine.givenName}.{machine.user.name}.{magic}
<CopyIcon className='w-3 h-3'/>
</Menu.ItemButton>
) : undefined}
{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'>
<td className="py-2">
<span className={cn(
'flex items-center gap-x-1 text-sm',
'text-gray-500 dark:text-gray-400'
'text-gray-500 dark:text-gray-400',
)}
>
<StatusCircle
isOnline={machine.online && !expired}
className='w-4 h-4'
className="w-4 h-4"
/>
<p>
{machine.online && !expired
? 'Connected'
: new Date(
machine.lastSeen
machine.lastSeen,
).toLocaleString()}
</p>
</span>
</td>
<td className='py-2 pr-0.5'>
<Rename
machine={machine}
fetcher={fetcher}
state={renameState}
magic={magic}
/>
<Delete
machine={machine}
fetcher={fetcher}
state={removeState}
/>
{expired ? undefined : (
<Expire
machine={machine}
fetcher={fetcher}
state={expireState}
/>
)}
<Routes
<td className="py-2 pr-0.5">
<MenuOptions
machine={machine}
routes={routes}
fetcher={fetcher}
state={routesState}
users={users}
magic={magic}
/>
<Menu>
<Menu.Button
className={cn(
'flex items-center justify-center',
'border border-transparent rounded-lg py-0.5 w-10',
'group-hover:border-gray-200 dark:group-hover:border-zinc-700'
)}
>
<KebabHorizontalIcon className='w-5'/>
</Menu.Button>
<Menu.Items>
<Menu.ItemButton control={renameState}>
Edit machine name
</Menu.ItemButton>
<Menu.ItemButton control={routesState}>
Edit route settings
</Menu.ItemButton>
<Menu.Item className='opacity-50 hover:bg-transparent'>
Edit ACL tags
</Menu.Item>
{expired ? undefined : (
<Menu.ItemButton control={expireState}>
Expire
</Menu.ItemButton>
)}
<Menu.ItemButton
className='text-red-500 dark:text-red-400'
control={removeState}
>
Remove
</Menu.ItemButton>
</Menu.Items>
</Menu>
</td>
</tr>
)
+114
View File
@@ -0,0 +1,114 @@
import { KebabHorizontalIcon } from '@primer/octicons-react'
import { ReactNode, 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
buttonChild?: ReactNode
}
export default function Menu({ machine, routes, magic, users, buttonChild }: 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'
|| machine.expiry === null
? 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>
{buttonChild ?? (
<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>
</>
)
}
+38 -76
View File
@@ -1,25 +1,29 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { InfoIcon } from '@primer/octicons-react'
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
import { useFetcher, useLoaderData } from '@remix-run/react'
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
import Code from '~/components/Code'
import { type Machine, type Route } from '~/types'
import Link from '~/components/Link'
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 { pull } from '~/utils/headscale'
import { getSession } from '~/utils/sessions'
import { useLiveData } from '~/utils/useLiveData'
import type { Machine, Route, User } from '~/types'
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] = await Promise.all([
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()
@@ -27,91 +31,50 @@ export async function loader({ request }: LoaderFunctionArgs) {
if (context.config.read) {
const config = await loadConfig()
if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain
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) {
const session = await getSession(request.headers.get('Cookie'))
if (!session.has('hsApiKey')) {
return json({ message: 'Unauthorized' }, {
status: 401,
})
}
const data = await request.formData()
if (!data.has('_method') || !data.has('id')) {
return json({ message: 'No method or ID provided' }, {
status: 400,
})
}
const id = String(data.get('id'))
const method = String(data.get('_method'))
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!)
return json({ message: 'Machine removed' })
}
case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
return json({ message: 'Machine expired' })
}
case 'rename': {
if (!data.has('name')) {
return json({ message: 'No name provided' }, {
status: 400,
})
}
const name = String(data.get('name'))
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
return json({ message: 'Machine renamed' })
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return json({ message: 'No route or enabled provided' }, {
status: 400,
})
}
const route = String(data.get('route'))
const enabled = data.get('enabled') === 'true'
const postfix = enabled ? 'enable' : 'disable'
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
return json({ message: 'Route updated' })
}
default: {
return json({ message: 'Invalid method' }, {
status: 400,
})
}
}
return menuAction(request)
}
export default function Page() {
useLiveData({ interval: 3000 })
const data = useLoaderData<typeof loader>()
const fetcher = useFetcher()
return (
<>
<h1 className="text-2xl font-medium mb-4">Machines</h1>
<div className="flex justify-between items-center mb-8">
<div className="flex flex-col w-2/3">
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
<p className='text-gray-700 dark:text-gray-300'>
Manage the devices connected to your Tailnet.
{' '}
<Link
to="https://tailscale.com/kb/1372/manage-devices"
name="Tailscale Manage Devices Documentation"
>
Learn more
</Link>
</p>
</div>
<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">
@@ -135,7 +98,7 @@ export default function Page() {
based on their name and also at
{' '}
<Code>
[name].[user].
[name].
{data.magic}
</Code>
</Tooltip>
@@ -155,10 +118,9 @@ export default function Page() {
{data.nodes.map(machine => (
<MachineRow
key={machine.id}
// Typescript isn't smart enough yet
machine={machine as unknown as Machine}
routes={data.routes.filter(route => route.node.id === machine.id) as unknown as Route[]}
fetcher={fetcher}
machine={machine}
routes={data.routes.filter(route => route.node.id === machine.id)}
users={data.users}
magic={data.magic}
/>
))}
-13
View File
@@ -1,13 +0,0 @@
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,45 @@
import Link from '~/components/Link'
import Button from '~/components/Button'
import { Link as RemixLink } from '@remix-run/react'
import { ArrowRightIcon } from '@primer/octicons-react'
import { cn } from '~/utils/cn'
export default function Page() {
return (
<div className="flex flex-col gap-8 max-w-screen-lg">
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Settings</h1>
<p className="text-gray-700 dark:text-gray-300">
The settings page is still under construction.
As I'm able to add more features, I'll be adding them here.
If you require any features, feel free to open an issue on
the GitHub repository.
</p>
</div>
<div className='flex flex-col w-2/3'>
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300">
Headscale fully supports pre-authentication keys in order to
easily add devices to your Tailnet.
To learn more about using pre-authentication keys, visit the
{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
>
Tailscale documentation
</Link>
</p>
</div>
<RemixLink to="/settings/auth-keys">
<span className={cn(
'text-lg font-medium',
'text-gray-700 dark:text-gray-300',
)}>
Manage Auth Keys
<ArrowRightIcon className="w-5 h-5 ml-2" />
</span>
</RemixLink>
</div>
)
}
@@ -0,0 +1,69 @@
import { useFetcher } from '@remix-run/react'
import type { PreAuthKey } from '~/types'
import { cn } from '~/utils/cn'
import Dialog from '~/components/Dialog'
import Spinner from '~/components/Spinner'
interface Props {
authKey: PreAuthKey
}
export default function ExpireKey({ authKey }: Props) {
const fetcher = useFetcher()
return (
<Dialog>
<Dialog.Button className="my-4">
Expire Key
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Expire auth key?
</Dialog.Title>
<fetcher.Form method="DELETE" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<input type="hidden" name="user" value={authKey.user} />
<input type="hidden" name="key" value={authKey.key} />
<Dialog.Text>
Expiring this authentication key will immediately
prevent it from being used to authenticate new devices.
{' '}
This action cannot be undone.
</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"
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}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Expire
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -0,0 +1,151 @@
import { RepoForkedIcon } from '@primer/octicons-react'
import { useFetcher } from '@remix-run/react'
import { useState } from 'react'
import Dialog from '~/components/Dialog'
import TextField from '~/components/TextField'
import NumberField from '~/components/NumberField'
import Tooltip from '~/components/Tooltip'
import Select from '~/components/Select'
import Switch from '~/components/Switch'
import Link from '~/components/Link'
import Spinner from '~/components/Spinner'
import { cn } from '~/utils/cn'
import { User } from '~/types'
interface Props {
users: User[]
}
// TODO: Tags
export default function AddPreAuthKey(data: Props) {
const fetcher = useFetcher()
const [user, setUser] = useState('')
const [reusable, setReusable] = useState(false)
const [ephemeral, setEphemeral] = useState(false)
const [aclTags, setAclTags] = useState([])
const [expiry, setExpiry] = useState(90)
return (
<Dialog>
<Dialog.Button className="my-4">
Create pre-auth key
</Dialog.Button>
<Dialog.Panel>
{close => (
<>
<Dialog.Title>
Generate auth key
</Dialog.Title>
<fetcher.Form method="POST" onSubmit={e => {
fetcher.submit(e.currentTarget)
close()
}}>
<Dialog.Text className="font-semibold">
User
</Dialog.Text>
<Dialog.Text className="text-sm">
Attach this key to a user
</Dialog.Text>
<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>
<Dialog.Text className="font-semibold mt-4">
Key Expiration
</Dialog.Text>
<Dialog.Text className="text-sm">
Set this key to expire between 1 and 90 days.
</Dialog.Text>
<NumberField
label="Expiry"
name="expiry"
minValue={1}
maxValue={90}
state={[expiry, setExpiry]}
formatOptions={{
style: 'unit',
unit: 'day',
unitDisplay: 'short',
}}
/>
<div className="flex justify-between items-center mt-6">
<div>
<Dialog.Text className="font-semibold">
Reusable
</Dialog.Text>
<Dialog.Text className="text-sm">
Use this key to authenticate more than one device.
</Dialog.Text>
</div>
<Switch
label="Reusable"
name="reusable"
defaultSelected={reusable}
onChange={() => { setReusable(!reusable) }}
/>
</div>
<input type="hidden" name="reusable" value={reusable.toString()} />
<div className="flex justify-between items-center mt-6">
<div>
<Dialog.Text className="font-semibold">
Ephemeral
</Dialog.Text>
<Dialog.Text className="text-sm">
Devices authenticated with this key will
be automatically removed once they go offline.
{' '}
<Link
to="https://tailscale.com/kb/1111/ephemeral-nodes"
name="Tailscale Ephemeral Nodes Documentation"
>
Learn more
</Link>
</Dialog.Text>
</div>
<Switch
label="Ephemeral"
name="ephemeral"
defaultSelected={ephemeral}
onChange={() => {
setEphemeral(!ephemeral)
}}
/>
</div>
<input type="hidden" name="ephemeral" value={ephemeral.toString()} />
<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}
isDisabled={!user || !expiry}
>
{fetcher.state === 'idle'
? undefined
: (
<Spinner className="w-3 h-3" />
)}
Generate
</Dialog.Action>
</div>
</fetcher.Form>
</>
)}
</Dialog.Panel>
</Dialog>
)
}
@@ -0,0 +1,55 @@
import type { PreAuthKey } from '~/types'
import { toast } from '~/components/Toaster'
import Code from '~/components/Code'
import Button from '~/components/Button'
import Attribute from '~/components/Attribute'
import ExpireKey from './dialogs/expire'
interface Props {
authKey: PreAuthKey
server: string
}
export default function AuthKeyRow({ authKey, server }: Props) {
const createdAt = new Date(authKey.createdAt).toLocaleString()
const expiration = new Date(authKey.expiration).toLocaleString()
return (
<div className="w-full">
<Attribute name="Key" value={authKey.key} isCopyable />
<Attribute name="User" value={authKey.user} isCopyable />
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
<Attribute name="Created" value={createdAt} />
<Attribute name="Expiration" value={expiration} />
<p className="mb-1 mt-4">
To use this key, run the following command on your device:
</p>
<Code className="text-sm">
tailscale up --login-server {server} --authkey {authKey.key}
</Code>
<div className="flex gap-4 items-center">
{authKey.used || new Date(authKey.expiration) < new Date()
? undefined
: (
<ExpireKey authKey={authKey} />
)}
<Button
variant="light"
className="my-4"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server ${server} --authkey ${authKey.key}`
)
toast('Copied command to clipboard')
}}
>
Copy Tailscale Command
</Button>
</div>
</div>
)
}
@@ -0,0 +1,223 @@
import { LoaderFunctionArgs, ActionFunctionArgs, json } from '@remix-run/node'
import { useLoaderData } from '@remix-run/react'
import { useLiveData } from '~/utils/useLiveData'
import { getSession } from '~/utils/sessions'
import { Link as RemixLink } from '@remix-run/react'
import { PreAuthKey, User } from '~/types'
import { pull, post } from '~/utils/headscale'
import { loadContext } from '~/utils/config/headplane'
import { useState } from 'react'
import Link from '~/components/Link'
import TableList from '~/components/TableList'
import Select from '~/components/Select'
import Switch from '~/components/Switch'
import AddPreAuthKey from './dialogs/new'
import AuthKeyRow from './key'
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()
// Expiring a pre-auth key
if (request.method === 'DELETE') {
const key = data.get('key')
const user = data.get('user')
if (!key || !user) {
return json({ message: 'Missing parameters' }, {
status: 400,
})
}
await post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey/expire',
session.get('hsApiKey')!,
{
user: user,
key: key,
}
)
return json({ message: 'Pre-auth key expired' })
}
// Creating a new pre-auth key
if (request.method === 'POST') {
const user = data.get('user')
const expiry = data.get('expiry')
const reusable = data.get('reusable')
const ephemeral = data.get('ephemeral')
if (!user || !expiry || !reusable || !ephemeral) {
return json({ message: 'Missing parameters' }, {
status: 400,
})
}
// Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(' ')[0])
const date = new Date()
date.setDate(date.getDate() + day)
const key = await post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.get('hsApiKey')!,
{
user: user,
ephemeral: ephemeral === 'on',
reusable: reusable === 'on',
expiration: date.toISOString(),
aclTags: [], // TODO
}
)
return json({ message: 'Pre-auth key created', key })
}
}
export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext()
const session = await getSession(request.headers.get('Cookie'))
const users = await pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!)
const preAuthKeys = await Promise.all(users.users.map(user => {
const qp = new URLSearchParams()
qp.set('user', user.name)
return pull<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('hsApiKey')!
)
}))
return {
keys: preAuthKeys.flatMap(keys => keys.preAuthKeys),
users: users.users,
server: context.headscaleUrl,
}
}
export default function Page() {
const { keys, users, server } = useLoaderData<typeof loader>()
const [user, setUser] = useState('All')
const [status, setStatus] = useState('Active')
useLiveData({ interval: 3000 })
const filteredKeys = keys.filter(key => {
if (user !== 'All' && key.user !== user) {
return false
}
if (status !== 'All') {
const now = new Date()
const expiry = new Date(key.expiration)
if (status === 'Active') {
return !(expiry < now) && !key.used
}
if (status === 'Used/Expired') {
return key.used || expiry < now
}
if (status === 'Reusable') {
return key.reusable
}
if (status === 'Ephemeral') {
return key.ephemeral
}
}
return true
})
return (
<div className='flex flex-col w-2/3'>
<p className="mb-8 text-md">
<RemixLink
to="/settings"
className="font-medium"
>
Settings
</RemixLink>
<span className="mx-2">
/
</span>
{' '}
Pre-Auth Keys
</p>
<h1 className='text-2xl font-medium mb-4'>Pre-Auth Keys</h1>
<p className="text-gray-700 dark:text-gray-300 mb-4">
Headscale fully supports pre-authentication keys in order to
easily add devices to your Tailnet.
To learn more about using pre-authentication keys, visit the
{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
>
Tailscale documentation
</Link>
</p>
<AddPreAuthKey users={users} />
<div className="flex justify-between gap-4 mt-4">
<div className="w-full">
<p className="text-sm text-gray-500 dark:text-gray-300">
Filter by user
</p>
<Select
label="Filter by User"
placeholder="Select a user"
state={[user, setUser]}
>
<Select.Item id="All">All</Select.Item>
{users.map(user => (
<Select.Item key={user.id} id={user.name}>
{user.name}
</Select.Item>
))}
</Select>
</div>
<div className="w-full">
<p className="text-sm text-gray-500 dark:text-gray-300">
Filter by status
</p>
<Select
label="Filter by status"
placeholder="Select a status"
state={[status, setStatus]}
>
<Select.Item id="All">All</Select.Item>
<Select.Item id="Active">Active</Select.Item>
<Select.Item id="Used/Expired">Used/Expired</Select.Item>
<Select.Item id="Reusable">Reusable</Select.Item>
<Select.Item id="Ephemeral">Ephemeral</Select.Item>
</Select>
</div>
</div>
<TableList className="mt-4">
{filteredKeys.length === 0 ? (
<TableList.Item>
<p className="opacity-50 text-sm mx-auto">
No pre-auth keys
</p>
</TableList.Item>
) : filteredKeys.map(key => (
<TableList.Item key={key.id}>
<AuthKeyRow authKey={key} server={server} />
</TableList.Item>
))}
</TableList>
</div>
)
}
+1 -1
View File
@@ -35,7 +35,7 @@ export async function loader({ request }: LoaderFunctionArgs) {
const context = await loadContext()
return {
...context,
config: context.config,
user: session.get('user'),
}
}
-1
View File
@@ -36,7 +36,6 @@ export default function Add({ magic }: Props) {
{' '}
<Code>
[machine].
{username.length > 0 ? username : '[username]'}
.
{magic}
</Code>
+1 -20
View File
@@ -8,10 +8,9 @@ import Dialog from '~/components/Dialog'
interface Props {
username: string
magic?: string
}
export default function Remove({ username, magic }: Props) {
export default function Remove({ username }: Props) {
const submit = useSubmit()
const dialogState = useState(false)
@@ -41,24 +40,6 @@ export default function Remove({ username, magic }: Props) {
?
{' '}
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"
-28
View File
@@ -3,7 +3,6 @@ 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'
@@ -40,33 +39,6 @@ export default function Rename({ username, magic }: Props) {
Enter a new username for
{' '}
{username}
?
{magic
? (
<p className="text-sm mt-8 text-ui-600 dark:text-ui-300">
{' '}
Since Magic DNS is enabled, machines
currently accessible via
{' '}
<Code>
[machine].
{username}
.
{magic}
</Code>
{' '}
will now become accessible via
{' '}
<Code>
[machine].
{newName.length > 0 ? newName : '[new-username]'}
.
{magic}
</Code>
.
</p>
)
: undefined}
</Dialog.Text>
<Form
method="POST"
+14 -19
View File
@@ -1,7 +1,7 @@
/* eslint-disable @typescript-eslint/no-non-null-assertion */
import { type DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
import { 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 { ActionFunctionArgs, json, 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'
@@ -10,7 +10,7 @@ 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 { Machine, User } from '~/types'
import { cn } from '~/utils/cn'
import { loadContext } from '~/utils/config/headplane'
import { loadConfig } from '~/utils/config/headscale'
@@ -41,8 +41,8 @@ export async function loader({ request }: LoaderFunctionArgs) {
if (context.config.read) {
const config = await loadConfig()
if (config.dns_config.magic_dns) {
magic = config.dns_config.base_domain
if (config.dns.magic_dns) {
magic = config.dns.base_domain
}
}
@@ -182,15 +182,12 @@ export default function Page() {
<Auth magic={data.magic} />
)}
<ClientOnly fallback={
// @ts-expect-error: JsonifyObject is weird
<Users users={users} />
}
>
{() => (
<InteractiveUsers
// @ts-expect-error: JsonifyObject is weird
users={users}
// @ts-expect-error: JsonifyObject is weird
setUsers={setUsers}
magic={data.magic}
/>
@@ -211,11 +208,10 @@ interface UserProps {
function Users({ users, magic }: UserProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map((user, i) => (
{users.map(user => (
<UserCard
key={user.id}
user={user}
isFirst={i === 0}
magic={magic}
/>
))}
@@ -267,11 +263,10 @@ function InteractiveUsers({ users, setUsers, magic }: UserProps) {
}}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map((user, i) => (
{users.map(user => (
<UserCard
key={user.id}
user={user}
isFirst={i === 0}
magic={magic}
/>
))}
@@ -304,6 +299,7 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
<StatusCircle isOnline={machine.online} className="w-4 h-4 px-1 w-fit" />
<Attribute
name={machine.givenName}
link={`machines/${machine.id}`}
value={machine.ipAddresses[0]}
/>
</div>
@@ -312,11 +308,10 @@ function MachineChip({ machine }: { readonly machine: Machine }) {
interface CardProps {
user: UserMachine
isFirst: boolean
magic?: string
}
function UserCard({ user, isFirst, magic }: CardProps) {
function UserCard({ user, magic }: CardProps) {
const { isOver, setNodeRef } = useDroppable({
id: user.name,
})
@@ -339,11 +334,11 @@ function UserCard({ user, isFirst, magic }: CardProps) {
</div>
<div className="flex items-center gap-2">
<Rename username={user.name} magic={magic} />
{isFirst
? undefined
: (
<Remove username={user.name} magic={magic} />
)}
{user.machines.length === 0
? (
<Remove username={user.name} />
)
: undefined}
</div>
</div>
<div className="mt-4">
+2 -2
View File
@@ -80,8 +80,7 @@ export async function action({ request }: ActionFunctionArgs) {
}),
},
})
} catch (error) {
console.error(error)
} catch {
return json({
error: 'Invalid API key',
})
@@ -123,6 +122,7 @@ export default function Page() {
label="API Key"
name="api-key"
placeholder="API Key"
type="password"
/>
<Button
className="w-full mt-2.5"
+21
View File
@@ -7,3 +7,24 @@
scrollbar-gutter: stable
}
}
.cm-merge-theme {
height: 100% !important;
}
.cm-mergeView {
height: 100% !important;
}
.cm-mergeViewEditors {
height: 100%;
}
.cm-mergeViewEditor {
height: 100% !important;
}
/* Weirdest class name characters but ok */
.cm-mergeView .ͼ1 .cm-scroller, .cm-mergeView .ͼ1 {
height: 100% !important;
}
+18 -18
View File
@@ -1,28 +1,28 @@
import type { User } from './User'
export type Machine = {
id: string;
machineKey: string;
nodeKey: string;
discoKey: string;
ipAddresses: string[];
name: string;
export interface Machine {
id: string
machineKey: string
nodeKey: string
discoKey: string
ipAddresses: string[]
name: string
user: User;
lastSeen: Date;
expiry: Date;
user: User
lastSeen: string
expiry: string
preAuthKey?: unknown; // TODO
preAuthKey?: unknown // TODO
createdAt: Date;
createdAt: string
registerMethod: 'REGISTER_METHOD_UNSPECIFIED'
| 'REGISTER_METHOD_AUTH_KEY'
| 'REGISTER_METHOD_CLI'
| 'REGISTER_METHOD_OIDC';
| 'REGISTER_METHOD_OIDC'
forcedTags: string[];
invalidTags: string[];
validTags: string[];
givenName: string;
online: boolean;
forcedTags: string[]
invalidTags: string[]
validTags: string[]
givenName: string
online: boolean
}
+11
View File
@@ -0,0 +1,11 @@
export interface PreAuthKey {
id: string
key: string
user: string
reusable: boolean
ephemeral: boolean
used: boolean
expiration: string
createdAt: string
aclTags: string[]
}
+10 -10
View File
@@ -1,13 +1,13 @@
import type { Machine } from './Machine'
export type Route = {
id: string;
node: Machine;
prefix: string;
advertised: boolean;
enabled: boolean;
isPrimary: boolean;
createdAt: Date;
updatedAt: Date;
deletedAt: Date;
export interface Route {
id: string
node: Machine
prefix: string
advertised: boolean
enabled: boolean
isPrimary: boolean
createdAt: string
updatedAt: string
deletedAt: string
}
+4 -4
View File
@@ -1,5 +1,5 @@
export type User = {
id: string;
name: string;
createdAt: Date;
export interface User {
id: string
name: string
createdAt: string
}
+1
View File
@@ -2,3 +2,4 @@ export * from './Key'
export * from './Machine'
export * from './Route'
export * from './User'
export * from './PreAuthKey'
+71 -131
View File
@@ -8,28 +8,21 @@ import { resolve } from 'node:path'
import { parse } from 'yaml'
import { HeadscaleConfig, loadConfig } from './headscale'
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
}
acl: {
read: boolean
write: boolean
}
docker?: {
url: string
sock: boolean
container: string
}
oidc?: {
issuer: string
client: string
@@ -46,12 +39,20 @@ export async function loadContext(): Promise<HeadplaneContext> {
return context
}
let config: HeadscaleConfig | undefined
try {
config = await loadConfig()
} catch {}
const envFile = process.env.LOAD_ENV_FILE === 'true'
if (envFile) {
log.info('CTXT', 'Loading environment variables from .env')
await import('dotenv/config')
}
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) {
@@ -72,146 +73,68 @@ export async function loadContext(): Promise<HeadplaneContext> {
}
context = {
debug,
headscaleUrl,
cookieSecret,
config: await checkConfig(path, config),
acl: await checkAcl(config),
docker: await checkDocker(),
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
}
export async function loadAcl() {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await loadConfig()
path = config.acl_policy_path
} catch {}
}
async function checkConfig(path: string) {
log.debug('CTXT', 'Checking config at %s', path)
if (!path) {
return { data: '', type: 'json' }
}
const data = await readFile(path, 'utf8')
// Naive check for YAML over JSON
// This is because JSON.parse doesn't support comments
let config: HeadscaleConfig | undefined
try {
parse(data)
return { data, type: 'yaml' }
config = await loadConfig(path)
} catch {
return { data, type: 'json' }
}
}
export async function patchAcl(data: string) {
let path = process.env.ACL_FILE
if (!path) {
try {
const config = await loadConfig()
path = config.acl_policy_path
} catch {}
log.debug('CTXT', 'Config at %s failed to load', path)
return {
config: undefined,
contextData: {
read: false,
write: false,
},
}
}
if (!path) {
throw new Error('No ACL file defined')
}
await writeFile(path, data, 'utf8')
}
async function checkConfig(path: string, config?: HeadscaleConfig) {
let write = false
try {
log.debug('CTXT', 'Checking write access to %s', path)
await access(path, constants.W_OK)
write = true
} catch {}
return {
read: config ? true : false,
write,
}
}
async function checkAcl(config?: HeadscaleConfig) {
let path = process.env.ACL_FILE
if (!path && config) {
path = config.acl_policy_path
}
let read = false
let write = false
if (path) {
try {
await access(path, constants.R_OK)
read = true
} catch {}
try {
await access(path, constants.W_OK)
write = true
} catch {}
}
return {
read,
write,
}
}
async function checkDocker() {
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
let url: URL | undefined
try {
url = new URL(path)
} catch {
return
}
// The API is available as an HTTP endpoint
if (url.protocol === 'tcp:') {
url.protocol = 'http:'
}
// Check if the socket is accessible
if (url.protocol === 'unix:') {
try {
await access(path, constants.R_OK)
} catch {
return
}
}
if (url.protocol === 'http:') {
try {
await fetch(new URL('/v1.30/version', url).href)
} catch {
return
}
}
if (url.protocol !== 'http:' && url.protocol !== 'unix:') {
return
}
if (!process.env.HEADSCALE_CONTAINER) {
return
log.debug('CTXT', 'No write access to %s', path)
}
return {
url: url.href,
sock: url.protocol === 'unix:',
container: process.env.HEADSCALE_CONTAINER,
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')
@@ -221,6 +144,10 @@ async function checkOidc(config?: HeadscaleConfig) {
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)
@@ -229,12 +156,23 @@ async function checkOidc(config?: HeadscaleConfig) {
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,
@@ -244,7 +182,9 @@ async function checkOidc(config?: HeadscaleConfig) {
if (data && data.length > 0) {
secret = data.trim()
}
} catch {}
} catch {
log.error('CTXT', 'Failed to read OIDC client secret from %s', config.oidc.client_secret_path)
}
}
}
+151 -34
View File
@@ -12,6 +12,20 @@ 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'),
@@ -26,12 +40,11 @@ const HeadscaleConfig = z.object({
listen_addr: z.string(),
metrics_listen_addr: z.string().optional(),
grpc_listen_addr: z.string().default(':50443'),
grpc_allow_insecure: z.boolean().default(false),
grpc_allow_insecure: goBool.default(false),
disable_check_updates: z.boolean().default(false),
ephemeral_node_inactivity_timeout: z.string().default('120s'),
randomize_client_port: z.boolean().default(false),
acl_policy_path: z.string().optional(),
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(),
@@ -39,8 +52,13 @@ const HeadscaleConfig = z.object({
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: z.string().default('800ms'),
batch_change_delay: goDuration.default('800ms'),
node_mapsession_buffered_chan_size: z.number().default(30),
}).optional(),
@@ -54,14 +72,14 @@ const HeadscaleConfig = z.object({
}).default({ level: 'info', format: 'text' }),
logtail: z.object({
enabled: z.boolean().default(false),
enabled: goBool.default(false),
}).default({ enabled: false }),
cli: z.object({
address: z.string().optional(),
api_key: z.string().optional(),
timeout: z.string().default('10s'),
insecure: z.boolean().default(false),
timeout: goDuration.default('10s'),
insecure: goBool.default(false),
}).optional(),
prefixes: z.object({
@@ -70,61 +88,63 @@ const HeadscaleConfig = z.object({
v6: z.string(),
}),
dns_config: z.object({
override_local_dns: z.boolean().default(true),
nameservers: z.array(z.string()).default([]),
restricted_nameservers: z.record(z.array(z.string())).default({}),
domains: z.array(z.string()).default([]),
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([]),
magic_dns: z.boolean().default(false),
base_domain: z.string().default('headscale.net'),
use_username_in_magic_dns: goBool.default(false),
}),
oidc: z.object({
only_start_if_oidc_is_available: z.boolean().default(true),
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.string()).default({}),
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: z.boolean().default(true),
expiry: z.string().default('180d'),
use_expiry_from_token: z.boolean().default(false),
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: z.boolean().default(false),
debug: goBool.default(false),
sqlite: z.object({
path: z.string(),
}),
}),
z.object({
type: z.literal('sqlite3'),
debug: z.boolean().default(false),
debug: goBool.default(false),
sqlite: z.object({
path: z.string(),
}),
}),
z.object({
type: z.literal('postgres'),
debug: z.boolean().default(false),
debug: goBool.default(false),
postgres: z.object({
host: z.string(),
port: z.number(),
name: z.string(),
user: z.string(),
pass: z.string(),
ssl: z.boolean().default(false),
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),
@@ -134,7 +154,7 @@ const HeadscaleConfig = z.object({
derp: z.object({
server: z.object({
enabled: z.boolean().default(false),
enabled: goBool.default(true),
region_id: z.number().optional(),
region_code: z.string().optional(),
region_name: z.string().optional(),
@@ -143,13 +163,13 @@ const HeadscaleConfig = z.object({
ipv4: z.string().optional(),
ipv6: z.string().optional(),
automatically_add_embedded_derp_region: z.boolean().default(true),
automatically_add_embedded_derp_region: goBool.default(true),
}),
urls: z.array(z.string()).optional(),
paths: z.array(z.string()).optional(),
auto_update_enabled: z.boolean().default(true),
update_frequency: z.string().default('24h'),
auto_update_enabled: goBool.default(true),
update_frequency: goDuration.default('24h'),
}),
})
@@ -158,16 +178,102 @@ export type HeadscaleConfig = z.infer<typeof HeadscaleConfig>
export let configYaml: Document | undefined
export let config: HeadscaleConfig | undefined
export async function loadConfig() {
export async function loadConfig(path?: string) {
if (config) {
return config
}
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
const data = await readFile(path, 'utf8')
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)
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
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
}
@@ -177,7 +283,9 @@ export async function patchConfig(partial: Record<string, unknown>) {
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.
@@ -206,10 +314,19 @@ export async function patchConfig(partial: Record<string, unknown>) {
// Push the remaining element
path.push(temp.replaceAll('"', ''))
if (value === null) {
configYaml.deleteIn(path)
continue
}
configYaml.setIn(path, value)
}
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
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')
}
-76
View File
@@ -1,76 +0,0 @@
import { setTimeout } from 'node:timers/promises'
import { Client } from 'undici'
import { loadContext } from './config/headplane'
import { HeadscaleError, pull } from './headscale'
export async function sighupHeadscale() {
const context = await loadContext()
if (!context.docker) {
return
}
// Supports the DOCKER_SOCK environment variable
const client = context.docker.sock
? new Client('http://localhost', {
socketPath: context.docker.url,
})
: new Client(context.docker.url)
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${context.docker.container}/kill?signal=SIGHUP`,
})
if (!response.statusCode || response.statusCode !== 204) {
throw new Error('Failed to send SIGHUP to Headscale')
}
}
export async function restartHeadscale() {
const context = await loadContext()
if (!context.docker) {
return
}
// Supports the DOCKER_SOCK environment variable
const client = context.docker.sock
? new Client('http://localhost', {
socketPath: context.docker.url,
})
: new Client(context.docker.url)
const response = await client.request({
method: 'POST',
path: `/v1.30/containers/${context.docker.container}/restart`,
})
if (!response.statusCode || response.statusCode !== 204) {
throw new Error('Failed to restart Headscale')
}
// Wait for Headscale to restart before continuing
let attempts = 0
// eslint-disable-next-line
while (true) {
try {
// Acceptable blank because ROOT_API_KEY is not required
await pull('v1/apikey', context.oidc?.rootKey ?? '')
return
} catch (error) {
// This means the server is up but the API key is invalid
// This can happen if the user only uses ROOT_API_KEY via cookies
if (error instanceof HeadscaleError && error.status === 401) {
break
}
if (attempts > 10) {
throw new Error('Headscale did not restart in time')
}
attempts++
await setTimeout(1000)
}
}
}
+47
View File
@@ -1,4 +1,5 @@
import { loadContext } from './config/headplane'
import log from './log'
export class HeadscaleError extends Error {
status: number
@@ -18,8 +19,14 @@ export class FatalError extends Error {
}
export async function pull<T>(url: string, key: string) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?')
}
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}`,
@@ -27,6 +34,7 @@ export async function pull<T>(url: string, key: string) {
})
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)
}
@@ -34,8 +42,14 @@ export async function pull<T>(url: string, key: string) {
}
export async function post<T>(url: string, key: string, body?: unknown) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?')
}
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,
@@ -45,6 +59,32 @@ export async function post<T>(url: string, key: string, body?: unknown) {
})
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) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?')
}
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)
}
@@ -52,8 +92,14 @@ export async function post<T>(url: string, key: string, body?: unknown) {
}
export async function del<T>(url: string, key: string) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?')
}
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: {
@@ -62,6 +108,7 @@ export async function del<T>(url: string, key: string) {
})
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)
}
+29
View File
@@ -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)
}
+5 -5
View File
@@ -2,7 +2,7 @@ import { redirect } from '@remix-run/node'
import {
authorizationCodeGrantRequest,
calculatePKCECodeChallenge,
type Client,
Client,
discoveryRequest,
generateRandomCodeVerifier,
generateRandomNonce,
@@ -51,8 +51,8 @@ export async function startOidc(oidc: OidcConfig, req: Request) {
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') ?? ''
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
callback.host = req.headers.get('Host') ?? ''
const authUrl = new URL(processed.authorization_endpoint)
authUrl.searchParams.set('client_id', oidcClient.client_id)
@@ -119,8 +119,8 @@ export async function finishOidc(oidc: OidcConfig, req: Request) {
}
const callback = new URL('/admin/oidc/callback', req.url)
callback.protocol = req.url.includes('localhost') ? 'http:' : 'https:'
callback.hostname = req.headers.get('Host') ?? ''
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
callback.host = req.headers.get('Host') ?? ''
const tokenResponse = await authorizationCodeGrantRequest(
processed,
+1 -2
View File
@@ -27,9 +27,8 @@ export const {
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
sameSite: 'lax',
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secrets: [process.env.COOKIE_SECRET!],
secure: true
secure: process.env.COOKIE_SECURE !== 'false',
}
}
)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 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.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+1 -2
View File
@@ -2,14 +2,13 @@
# IT IS NOT AN EXAMPLE OF SOMETHING YOU DEPLOY
# I ONLY USE IT FOR DEVELOPING HEADPLANE
version: '3.9'
networks:
headplane-dev:
name: 'headplane-dev'
driver: 'bridge'
services:
headscale:
image: 'headscale/headscale:0.23.0-alpha5'
image: 'headscale/headscale:0.23.0-beta2'
container_name: 'headscale'
restart: 'unless-stopped'
command: 'serve'
+53 -112
View File
@@ -1,130 +1,71 @@
# Advanced Integration
![Integration Preview](/assets/advanced-preview.png)
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.
With the advanced integration it's possible to control Access Control Lists (ACLs) and the Headscale configuration via the Headplane UI.
Every single aspect of this integration is optional, meaning you can only use what you want.
If you want to use this integration, you do not need Docker and you can make it work with Headscale and Headplane running natively.
### Configuration Management
### Configuration Editing
<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>
When the configuration file is available to Headplane, the `DNS` and `Settings` tabs will become functional.
Similar to the Tailscale UI, you'll be able to edit the configuration without needing to manually edit the file.
Headscale will read the file from the path given in the `CONFIG_FILE` environment variable.
By default this is set to `/etc/headscale/config.yaml`.
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.
> One important think to note is that environment variables always take priority over the configuration file.
> The `HEADSCALE_URL`, `OIDC_CLIENT_ID`, `OIDFC_ISSUER`, and `OIDC_CLIENT_SECRET` will be preferred over the configuration file if available.
> 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)
![ACL Preview](/assets/acl-preview.png)
<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>
Headplane will enable the `Access Controls` tab if it is able to read an ACL file from Headscale.<br>
The ACL file path is read from the following sources in order of priority:
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.
- **Environment Variable**: If you set the `ACL_FILE` environment variable, Headplane will read the file from that path.
- **Configuration Integration**: If you've set this up, then Headplane will read the `acl_policy_path` key from the configuration file.
### Docker Integration
The Docker integration can be used to automatically reload the configuration or ACLs when they are changed.
In order for this to work, you'll need to pass in the `HEADSCALE_CONTAINER` environment variable.
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.
> 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 alpha or later
- Headscale 0.23 or newer
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
- Headscale needs to be running in a docker container
Here's a good Docker Compose example:
```yaml
version: '3.8'
services:
headscale:
image: 'headscale/headscale:0.23.0-alpha5'
container_name: 'headscale'
restart: 'unless-stopped'
command: 'serve'
volumes:
- './data:/var/lib/headscale'
- './configs:/etc/headscale'
ports:
- '8080:8080'
environment:
TZ: 'America/New_York'
headplane:
container_name: headplane
image: ghcr.io/tale/headplane:latest
restart: unless-stopped
volumes:
- './data:/var/lib/headscale'
- './configs:/etc/headscale'
- '/var/run/docker.sock:/var/run/docker.sock:ro'
ports:
- '3000:3000'
environment:
# This is always required for Headplane to work
COOKIE_SECRET: 'abcdefghijklmnopqrstuvwxyz'
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)
HEADSCALE_CONTAINER: 'headscale'
DISABLE_API_KEY_LOGIN: 'true'
HOST: '0.0.0.0'
PORT: '3000'
# Overrides the configuration file values if they are set in config.yaml
# If you want to share the same OIDC configuration you do not need this
OIDC_CLIENT_ID: 'headscale'
OIDC_ISSUER: 'https://sso.example.com'
OIDC_CLIENT_SECRET: 'super_secret_client_secret'
# This NEEDS to be set with OIDC, regardless of what's in the config
# This needs to be a very long-lived (999 day) API key used to create
# shorter ones for OIDC and allow the OIDC functionality to work
ROOT_API_KEY: 'abcdefghijklmnopqrstuvwxyz'
```
> For a breakdown of each configuration variable, please refer to the [Configuration](/docs/Configuration.md) guide.
> It explains what each variable does, how to configure them, and what the default values are.
You may also choose to run it natively with the distributed binaries on the releases page.
You'll need to manage running this yourself, and I would recommend making a `systemd` unit.
## Configuration Scenarios
Since the configuration is fairly modular you can have a variety of different setups.<br>
Here are a few examples to inspire you and show you what can work and what can't:
#### Full Integration
Headscale runs in a container, Headplane can run in either a container or natively.
Headplane is able to manage the configuration file and ACLs that Headscale uses.
When changes happen, the Docker integration will automatically reload the configuration and ACLs.
> Note that the full integration currently isn't possible if Headscale isn't running in a container.
#### Configuration Only
Headscale and Headplane can either run in containers or natively.
Headplane is able to manage the configuration file and ACLs that Headscale uses.
When changes are made, Headscale will need to be manually restarted to apply the changes.
#### ACL Only
Headscale and Headplane can either run in containers or natively.
Headplane is able to manage the ACLs that Headscale uses.
When changes are made, Headscale will need to be sent a `SIGHUP` to reload the ACLs.
In this scenario, Headplane does not have access to the configuration file.
#### Read-Only Configuration or ACLs
If the configuration or ACLs are read-only, Headplane will not be able to manage them.
Instead you'll only be able to view the configurations on the UI and need to edit them manually.
#### No Integration
If no integration is setup, Headplane will not be able to manage the configuration or ACLs.
This is the simplest setup by far, however it also heavily reduces the capabilities of Headplane.
+23 -20
View File
@@ -1,28 +1,35 @@
# Basic Integration
The basic integration is not able to offer advanced features such as:
- Automatic management of Access Control Lists (ACLs)
- Management of DNS settings for your tailnet
- Management of the Headscale configuration
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).
In order to support these features please refer to the [Advanced Integration](./docs/Advanced-Integration.md) guide.
Note that in order to use this deployment strategy you need to run Headscale in a Docker container.
> 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 alpha or later
- Headscale 0.23 or newer
- Headscale and Headplane need a Reverse Proxy (NGINX, Traefik, Caddy, etc)
Headplane is currently best run in a Docker container due to the easy configuration.
Here's a very basic `docker-compose.yaml` file that utilizes each configuration variable.
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
version: '3.8'
services:
headplane:
container_name: headplane
image: ghcr.io/tale/headplane:latest
image: ghcr.io/tale/headplane:0.3.5
restart: unless-stopped
ports:
- '3000:3000'
@@ -36,18 +43,14 @@ services:
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.
You may also choose to run it natively with the distributed binaries on the releases page.
You'll need to manage running this yourself, and I would recommend making a `systemd` unit.
## ACL Configuration
If you would like to get the web ACL configuration working, you'll need to pass the `ACL_FILE` environment variable.
This should point to the path of the ACL file on the Headscale server (ie. `ACL_FILE=/etc/headscale/acl_policy.json`).
> 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.
+4 -1
View File
@@ -9,10 +9,13 @@ You can configure Headplane using environment variables.
#### 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`).
- **`ACL_FILE`**: The path to the ACL file (default: `/etc/headscale/acl_policy.json`, not needed if you have `acl_policy_path` in your config).
- **`HEADSCALE_CONFIG_UNSTRICT`**: This will disable the strict configuration loader (default: `false`).
- **`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`).
- **`LOAD_ENV_FILE`**: Tell Headplane to read the `.env` file and load it into the environment (default: `false`).
#### Docker Integration
The Docker integration allows Headplane to manage the Headscale docker container.
+89
View File
@@ -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.5
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.
+133
View File
@@ -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.5
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.
+69
View File
@@ -0,0 +1,69 @@
## 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
1. Follow the instructions to install Headscale from the
[Linux Installation Guide](https://headscale.net/setup/install/official).
2. Install [Node.js](https://nodejs.org/en/download/package-manager)
version 20 or higher (your package manager most likely already has this).
3. Install [PNPM](https://pnpm.io/installation). This is required
as Headplane has issues running correctly when installed and built via NPM or Yarn.
4. Clone the Headplane repository, install dependencies, and build the project:
```sh
git clone https://github.com/tale/headplane # (or clone via SSH)
cd headplane
pnpm install
pnpm build
```
### Running Headplane
Start Headplane with `node build/headplane/server.js`.
Headplane does need various environment variables to run correctly. The required
variables can be found in the [Configuration](/docs/Configuration.md) guide.
If you choose to do this with a `.env` file, you can use the `LOAD_ENV_FILE`
variable to tell Headplane to load the file.
Finally, make sure to set `HEADSCALE_INTEGRATION=proc` to take advantage
of controlling Headscale natively on Linux.
```sh
LOAD_ENV_FILE=true node ./build/headplane/server.js
```
> 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.
### Cannot find ./build directory?
Headplane expects the `build` directory to be present when running the server.
The structure of this folder is very important and should not be tampered with.
If you would like to keep the build directory in a different location, you can
set the `BUILD_PATH` environment variable to the path of the build directory
at runtime.
```sh
LOAD_ENV_FILE=true BUILD_PATH=/path/to/build node ./build/headplane/server.js
```
### Changing Headplane's Path from `/admin`
Additionally, because you are building Headplane from source, you're able to
change the default path that Headplane is served from. This can be done by
specifying the `__INTERNAL_PREFIX` environment variable, when building.
```sh
__INTERNAL_PREFIX=/my-admin-path pnpm build
```
> Keep in mind that this is very much an experimental feature. Things can easily
> break and until it's more stable, it's not recommended to use in production.
-5
View File
@@ -1,5 +0,0 @@
import tale from 'eslint-config-tale'
export default [
...tale
]
+38 -33
View File
@@ -4,57 +4,55 @@
"sideEffects": false,
"type": "module",
"scripts": {
"build": "remix vite:build",
"build": "remix vite:build && vite build",
"dev": "remix vite:dev",
"lint": "eslint --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/server/index.js",
"typecheck": "tsc"
},
"dependencies": {
"@codemirror/lang-json": "^6.0.1",
"@codemirror/lang-yaml": "^6.1.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@primer/octicons-react": "^19.9.0",
"@react-aria/toast": "3.0.0-beta.11",
"@react-stately/toast": "3.0.0-beta.3",
"@remix-run/node": "^2.9.2",
"@remix-run/react": "^2.9.2",
"@remix-run/serve": "^2.9.2",
"@uiw/codemirror-theme-github": "^4.22.0",
"@uiw/react-codemirror": "^4.22.0",
"@kubernetes/client-node": "^0.22.2",
"@primer/octicons-react": "^19.12.0",
"@react-aria/toast": "3.0.0-beta.12",
"@react-stately/toast": "3.0.0-beta.4",
"@remix-run/node": "^2.13.1",
"@remix-run/react": "^2.13.1",
"@shopify/lang-jsonc": "^1.0.0",
"@uiw/codemirror-theme-github": "^4.23.6",
"@uiw/react-codemirror": "^4.23.6",
"clsx": "^2.1.1",
"isbot": "^5.1.6",
"oauth4webapi": "^2.10.4",
"dotenv": "^16.4.5",
"isbot": "^5.1.17",
"mime": "^4.0.4",
"oauth4webapi": "^2.17.0",
"react": "19.0.0-beta-26f2496093-20240514",
"react-aria-components": "^1.2.0",
"react-codemirror-merge": "^4.22.0",
"react-aria-components": "^1.2.1",
"react-codemirror-merge": "^4.23.6",
"react-dom": "19.0.0-beta-26f2496093-20240514",
"remix-utils": "^7.6.0",
"tailwind-merge": "^2.3.0",
"tailwindcss-react-aria-components": "^1.1.2",
"undici": "^6.16.1",
"react-error-boundary": "^4.1.2",
"remix-utils": "^7.7.0",
"tailwind-merge": "^2.5.4",
"tailwindcss-react-aria-components": "^1.1.6",
"undici": "^6.20.1",
"usehooks-ts": "^3.1.0",
"yaml": "^2.4.2",
"yaml": "^2.6.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@remix-run/dev": "^2.9.2",
"@remix-run/dev": "^2.13.1",
"@types/react": "npm:types-react@beta",
"@types/react-dom": "npm:types-react-dom@beta",
"autoprefixer": "^10.4.19",
"babel-plugin-react-compiler": "0.0.0-experimental-c23de8d-20240515",
"eslint": "^8.57.0",
"eslint-config-tale": "^2.0.4",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.3",
"autoprefixer": "^10.4.20",
"babel-plugin-react-compiler": "19.0.0-beta-8a03594-20241020",
"postcss": "^8.4.47",
"tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.4.5",
"vite": "^5.2.11",
"typescript": "^5.6.3",
"vite": "^5.4.10",
"vite-plugin-babel": "^1.2.0",
"vite-tsconfig-paths": "^4.2.1"
"vite-tsconfig-paths": "^5.1.0"
},
"overrides": {
"@types/react": "npm:types-react@beta",
@@ -66,7 +64,14 @@
},
"pnpm": {
"patchedDependencies": {
"@react-aria/overlays@3.22.0": "patches/@react-aria__overlays@3.22.0.patch"
"@react-aria/overlays@3.22.1": "patches/@react-aria__overlays@3.22.1.patch",
"@shopify/lang-jsonc@1.0.0": "patches/@shopify__lang-jsonc@1.0.0.patch"
},
"peerDependencyRules": {
"allowAny": [
"react",
"react-dom"
]
}
}
}
@@ -1,13 +1,13 @@
diff --git a/dist/usePreventScroll.mjs b/dist/usePreventScroll.mjs
index 532a72f9e9ba985d036b7eba0f950ff6552898d9..7c9263667c2463c8e84fd07064076132fe53284c 100644
index 69b84ce2aec5b637a9f0ba8158b6a1ba4173c266..5e546a6e4fc3b7a0468c1c89d887a4392c010624 100644
--- a/dist/usePreventScroll.mjs
+++ b/dist/usePreventScroll.mjs
@@ -48,7 +48,7 @@ function $49c51c25361d4cd2$export$ee0f7cc6afcd1c18(options = {}) {
// For most browsers, all we need to do is set `overflow: hidden` on the root element, and
// add some padding to prevent the page from shifting when the scrollbar is hidden.
function $49c51c25361d4cd2$var$preventScrollStandard() {
- return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, "paddingRight", `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"));
+ return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"));
- return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'));
+ return (0, $7mMvr$chain)($49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'));
}
// Mobile Safari is a whole different beast. Even with overflow: hidden,
// it still scrolls the page in many situations:
@@ -15,8 +15,8 @@ index 532a72f9e9ba985d036b7eba0f950ff6552898d9..7c9263667c2463c8e84fd07064076132
// enable us to scroll the window to the top, which is required for the rest of this to work.
let scrollX = window.pageXOffset;
let scrollY = window.pageYOffset;
- restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, "scroll", onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, "paddingRight", `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"), $49c51c25361d4cd2$var$setStyle(document.body, "marginTop", `-${scrollY}px`), ()=>{
+ restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, "scroll", onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, "overflow", "hidden"), $49c51c25361d4cd2$var$setStyle(document.body, "marginTop", `-${scrollY}px`), ()=>{
- restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, 'scroll', onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'paddingRight', `${window.innerWidth - document.documentElement.clientWidth}px`), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'), $49c51c25361d4cd2$var$setStyle(document.body, 'marginTop', `-${scrollY}px`), ()=>{
+ restoreStyles = (0, $7mMvr$chain)($49c51c25361d4cd2$var$addEvent(window, 'scroll', onWindowScroll), $49c51c25361d4cd2$var$setStyle(document.documentElement, 'overflow', 'hidden'), $49c51c25361d4cd2$var$setStyle(document.body, 'marginTop', `-${scrollY}px`), ()=>{
window.scrollTo(scrollX, scrollY);
});
// Scroll to the top. The negative margin on the body will make this appear the same.
+44
View File
@@ -0,0 +1,44 @@
diff --git a/dist/esm/index.js b/dist/esm/index.js
index 8b5b71f8eee6606ca8de47c15eef0ce73c01d93f..2bfa4e7c7ff650b35b1bdee75c28bd6fbf6c6704 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -1,3 +1,3 @@
-export { jsonc, jsoncLanguage } from './jsonc';
-export { parser } from './parser';
+export { jsonc, jsoncLanguage } from './jsonc.js';
+export { parser } from './parser.js';
//# sourceMappingURL=index.js.map
\ No newline at end of file
diff --git a/dist/esm/jsonc.js b/dist/esm/jsonc.js
index 5f968887d47696122c6bbfc4b98f94af6477f37a..b144021c4b5d4504c73f6c2172e8d1c2ddbfe3ac 100644
--- a/dist/esm/jsonc.js
+++ b/dist/esm/jsonc.js
@@ -1,4 +1,4 @@
-import { parser } from './parser';
+import { parser } from './parser.js';
import { continuedIndent, indentNodeProp, foldNodeProp, foldInside, LRLanguage, LanguageSupport, } from '@codemirror/language';
/// A language provider that provides JSON parsing.
export const jsoncLanguage = LRLanguage.define({
diff --git a/dist/esm/parser.js b/dist/esm/parser.js
index 3d966fcf7c55003b1ba6c2b3f531b7c9b8045cb8..f6e8f56c3506f76f31571a49af8cd44364a864a3 100644
--- a/dist/esm/parser.js
+++ b/dist/esm/parser.js
@@ -1,6 +1,6 @@
// This file was generated by lezer-generator. You probably shouldn't edit it.
import {LRParser} from "@lezer/lr"
-import {jsonHighlighting} from "./highlight"
+import {jsonHighlighting} from "./highlight.js"
export const parser = LRParser.deserialize({
version: 14,
states: "$zO]QPOOOOQO'#Cd'#CdOtQPO'#CgO|QPO'#ClOOQO'#Cr'#CrQOQPOOOOQO'#Ci'#CiO!TQPO'#ChO!YQPO'#CtOOQO,59R,59RO!bQPO,59RO!gQPO'#CwOOQO,59W,59WO!oQPO,59WO]QPO,59SO!tQPO,59`O!|QPO,59`OOQO1G.m1G.mO#UQPO,59cO#]QPO,59cOOQO1G.r1G.rOOQO1G.n1G.nOOQO,59X,59XO#eQPO1G.zOOQO-E6k-E6kOOQO,59Y,59YO#mQPO1G.}OOQO-E6l-E6lPwQPO'#CmP]QPO'#Cn",
diff --git a/package.json b/package.json
index 9c3a56db25535b7eb0b1f951abe486d530a2714f..5e858500f155c16ecb068854552a71e1da5f04fc 100644
--- a/package.json
+++ b/package.json
@@ -1,5 +1,6 @@
{
"name": "@shopify/lang-jsonc",
+ "type": "module",
"version": "1.0.0",
"description": "JSONC language support for CodeMirror",
"publishConfig": {
+2397 -3854
View File
File diff suppressed because it is too large Load Diff
Executable
+159
View File
@@ -0,0 +1,159 @@
// This is a polyglot entrypoint for Headplane when running in production
// It doesn't use any dependencies aside from @remix-run/node and mime
// During build we bundle the used dependencies into the file so that
// we can only need this file and a Node.js installation to run the server.
// PREFIX is defined globally, see vite.config.ts
import { access, constants } from 'node:fs/promises'
import { createReadStream, existsSync, statSync } from 'node:fs'
import { createServer } from 'node:http'
import { join, resolve } from 'node:path'
import { env } from 'node:process'
function log(level, message) {
const date = new Date().toISOString()
console.log(`${date} (${level}) [SRVX] ${message}`)
}
log('INFO', `Running with Node.js ${process.versions.node}`)
try {
await access('./node_modules/@remix-run', constants.F_OK | constants.R_OK)
log('INFO', 'Found node_modules dependencies')
} catch (error) {
log('ERROR', 'No node_modules found. Please run `pnpm install` first')
log('ERROR', error)
process.exit(1)
}
try {
await access('./build/server', constants.F_OK | constants.R_OK)
log('INFO', 'Found build directory')
} catch (error) {
const date = new Date().toISOString()
log('ERROR', 'No build directory found. Please run `pnpm build` first')
log('ERROR', error)
process.exit(1)
}
const {
createRequestHandler: remixRequestHandler,
createReadableStreamFromReadable,
writeReadableStreamToWritable
} = await import('@remix-run/node')
const { default: mime } = await import('mime')
const port = env.PORT || 3000
const host = env.HOST || '0.0.0.0'
const buildPath = env.BUILD_PATH || './build'
// Because this is a dynamic import without an easily discernable path
// we gain the "deoptimization" we want so that Vite doesn't bundle this
const build = await import(resolve(join(buildPath, 'server', 'index.js')))
const baseDir = resolve(join(buildPath, 'client'))
const handler = remixRequestHandler(build, 'production')
const http = createServer(async (req, res) => {
const url = new URL(`http://${req.headers.host}${req.url}`)
if (!url.pathname.startsWith(PREFIX)) {
res.writeHead(404)
res.end()
return
}
// We need to handle an issue where say we are navigating to $PREFIX
// but Remix does not handle it without the trailing slash. This is
// because Remix uses the URL constructor to parse the URL and it
// will remove the trailing slash. We need to redirect to the correct
// URL so that Remix can handle it correctly.
if (url.pathname === PREFIX) {
res.writeHead(302, {
Location: `${PREFIX}/`
})
res.end()
return
}
// Before we pass any requests to our Remix handler we need to check
// if we can handle a raw file request. This is important for the
// Remix loader to work correctly.
//
// To optimize this, we send them as readable streams in the node
// response and we also set headers for aggressive caching.
if (url.pathname.startsWith(`${PREFIX}/assets/`)) {
const filePath = join(baseDir, url.pathname.replace(PREFIX, ''))
const exists = existsSync(filePath)
const stats = statSync(filePath)
if (exists && stats.isFile()) {
// Build assets are cache-bust friendly so we can cache them heavily
if (req.url.startsWith('/build')) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable')
}
// Send the file as a readable stream
const fileStream = createReadStream(filePath)
const type = mime.getType(filePath)
res.setHeader('Content-Length', stats.size)
res.setHeader('Content-Type', type)
fileStream.pipe(res)
return
}
}
// Handling the request
const controller = new AbortController()
res.on('close', () => controller.abort())
const headers = new Headers()
for (const [key, value] of Object.entries(req.headers)) {
if (!value) continue
if (Array.isArray(value)) {
for (const v of value) {
headers.append(key, v)
}
continue
}
headers.append(key, value)
}
const remixReq = new Request(url.href, {
headers,
method: req.method,
signal: controller.signal,
// If we have a body we set a duplex and we load the body
...(req.method !== 'GET' && req.method !== 'HEAD' ? {
body: createReadableStreamFromReadable(req),
duplex: 'half'
} : {}
)
})
// Pass our request to the Remix handler and get a response
const response = await handler(remixReq, {}) // No context
// Handle our response and reply
res.statusCode = response.status
res.statusMessage = response.statusText
for (const [key, value] of response.headers.entries()) {
res.appendHeader(key, value)
}
if (response.body) {
await writeReadableStreamToWritable(response.body, res)
return
}
res.end()
})
http.listen(port, host, () => {
log('INFO', `Running on ${host}:${port}`)
})
+102 -39
View File
@@ -186,7 +186,8 @@ log:
# Path to a file containg ACL policies.
# ACLs can be defined as YAML or HUJSON.
# https://tailscale.com/kb/1018/acls/
acl_policy_path: /etc/headscale/acl.json
policy:
mode: 'database'
## DNS
#
@@ -197,7 +198,7 @@ acl_policy_path: /etc/headscale/acl.json
# - https://tailscale.com/kb/1081/magicdns/
# - https://tailscale.com/blog/2021-09-private-dns-with-magicdns/
#
dns_config:
dns_config2:
# Whether to prefer using Headscale provided DNS or use local.
override_local_dns: true
@@ -253,6 +254,68 @@ dns_config:
# The FQDN of the hosts will be
# `hostname.user.base_domain` (e.g., _myhost.myuser.example.com_).
base_domain: ts.net
extra_records:
- name: test.example.com
type: A
value: 1.1.1.1
dns:
# Whether to use [MagicDNS](https://tailscale.com/kb/1081/magicdns/).
# Only works if there is at least a nameserver defined.
magic_dns: true
# Defines the base domain to create the hostnames for MagicDNS.
# This domain _must_ be different from the server_url domain.
# `base_domain` must be a FQDN, without the trailing dot.
# The FQDN of the hosts will be
# `hostname.base_domain` (e.g., _myhost.example.com_).
base_domain: example.com
# List of DNS servers to expose to clients.
nameservers:
global:
- 1.1.1.1
- 1.0.0.1
- 2606:4700:4700::1111
- 2606:4700:4700::1001
# NextDNS (see https://tailscale.com/kb/1218/nextdns/).
# "abc123" is example NextDNS ID, replace with yours.
# - https://dns.nextdns.io/abc123
# Split DNS (see https://tailscale.com/kb/1054/dns/),
# a map of domains and which DNS server to use for each.
split:
{}
# foo.bar.com:
# - 1.1.1.1
# darp.headscale.net:
# - 1.1.1.1
# - 8.8.8.8
# Set custom DNS search domains. With MagicDNS enabled,
# your tailnet base_domain is always the first search domain.
search_domains: []
# Extra DNS records
# so far only A-records are supported (on the tailscale side)
# See https://github.com/juanfont/headscale/blob/main/docs/dns-records.md#Limitations
extra_records: []
# - name: "grafana.myvpn.example.com"
# type: "A"
# value: "100.64.0.3"
#
# # you can also put it in one line
# - { name: "prometheus.myvpn.example.com", type: "A", value: "100.64.0.3" }
# DEPRECATED
# Use the username as part of the DNS name for nodes, with this option enabled:
# node1.username.example.com
# while when this is disabled:
# node1.example.com
# This is a legacy option as Headscale has have this wrongly implemented
# while in upstream Tailscale, the username is not included.
use_username_in_magic_dns: false
# Unix socket used for the CLI to connect without authentication
# Note: for production you will want to set this to something like:
@@ -268,46 +331,46 @@ oidc:
issuer: "https://sso.example.com"
client_id: "headscale"
client_secret: "super_secret_client_secret"
# # Alternatively, set `client_secret_path` to read the secret from the file.
# # It resolves environment variables, making integration to systemd's
# # `LoadCredential` straightforward:
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
# # client_secret and client_secret_path are mutually exclusive.
#
# # The amount of time from a node is authenticated with OpenID until it
# # expires and needs to reauthenticate.
# # Setting the value to "0" will mean no expiry.
# # Alternatively, set `client_secret_path` to read the secret from the file.
# # It resolves environment variables, making integration to systemd's
# # `LoadCredential` straightforward:
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
# # client_secret and client_secret_path are mutually exclusive.
#
# # The amount of time from a node is authenticated with OpenID until it
# # expires and needs to reauthenticate.
# # Setting the value to "0" will mean no expiry.
expiry: 180d
#
# # Use the expiry from the token received from OpenID when the user logged
# # in, this will typically lead to frequent need to reauthenticate and should
# # only been enabled if you know what you are doing.
# # Note: enabling this will cause `oidc.expiry` to be ignored.
# use_expiry_from_token: false
#
# # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query
# # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".
#
# scope: ["openid", "profile", "email", "custom"]
# extra_params:
# domain_hint: example.com
#
# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
# # authentication request will be rejected.
#
#
# # Use the expiry from the token received from OpenID when the user logged
# # in, this will typically lead to frequent need to reauthenticate and should
# # only been enabled if you know what you are doing.
# # Note: enabling this will cause `oidc.expiry` to be ignored.
# use_expiry_from_token: false
#
# # Customize the scopes used in the OIDC flow, defaults to "openid", "profile" and "email" and add custom query
# # parameters to the Authorize Endpoint request. Scopes default to "openid", "profile" and "email".
#
# scope: ["openid", "profile", "email", "custom"]
# extra_params:
# domain_hint: example.com
#
# # List allowed principal domains and/or users. If an authenticated user's domain is not in this list, the
# # authentication request will be rejected.
#
allowed_domains:
- example.com
# # Note: Groups from keycloak have a leading '/'
# allowed_groups:
# - /headscale
# allowed_users:
# - alice@example.com
#
# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
# # This will transform `first-name.last-name@example.com` to the user `first-name.last-name`
# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
# user: `first-name.last-name.example.com`
#
# # Note: Groups from keycloak have a leading '/'
# allowed_groups:
# - /headscale
# allowed_users:
# - alice@example.com
#
# # If `strip_email_domain` is set to `true`, the domain part of the username email address will be removed.
# # This will transform `first-name.last-name@example.com` to the user `first-name.last-name`
# # If `strip_email_domain` is set to `false` the domain part will NOT be removed resulting to the following
# user: `first-name.last-name.example.com`
#
strip_email_domain: true
# Logtail configuration
+53 -17
View File
@@ -6,23 +6,59 @@ import tsconfigPaths from 'vite-tsconfig-paths'
installGlobals()
const ReactCompilerConfig = {}
const prefix = process.env.__INTERNAL_PREFIX || '/admin'
if (prefix.endsWith('/')) {
throw new Error('Prefix must not end with a slash')
}
export default defineConfig({
base: '/admin/',
plugins: [
remix({
basename: '/admin/',
}),
tsconfigPaths(),
babel({
filter: /\.[jt]sx?$/,
babelConfig: {
presets: ['@babel/preset-typescript'], // if you use TypeScript
plugins: [
['babel-plugin-react-compiler', ReactCompilerConfig],
],
export default defineConfig(({ isSsrBuild }) => {
// If we have the Headplane entry we build it as a single
// server.mjs file that is built for production server bundle
// We know the remix invoked command is vite:build
if (!process.argv.includes('vite:build') && !process.argv.includes('vite:dev')) {
return {
build: {
minify: false,
target: 'esnext',
rollupOptions: {
input: './server.mjs',
output: {
entryFileNames: 'server.js',
dir: 'build/headplane',
banner: '#!/usr/bin/env node\n',
},
external: (id) => id.startsWith('node:'),
}
},
}),
],
define: {
PREFIX: JSON.stringify(prefix),
},
resolve: {
alias: {
stream: 'node:stream',
crypto: 'node:crypto',
}
}
}
}
return ({
base: `${prefix}/`,
build: isSsrBuild ? { target: 'ES2022' } : {},
plugins: [
remix({
basename: `${prefix}/`,
}),
tsconfigPaths(),
babel({
filter: /\.[jt]sx?$/,
babelConfig: {
presets: ['@babel/preset-typescript'],
plugins: [
['babel-plugin-react-compiler', {}],
],
},
}),
],
})
})