Compare commits
147 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 41b1d3c847 | |||
| 712fc28683 | |||
| 320dab1d4f | |||
| da0ee1382b | |||
| a7d127c7bf | |||
| b433e607e2 | |||
| 8aad883c21 | |||
| 3cd28d2136 | |||
| 9d9cbd8e0e | |||
| feb8b8bba5 | |||
| c304effcdf | |||
| 982d811d53 | |||
| d369e36fa4 | |||
| a2e659f36c | |||
| 1490406784 | |||
| e1c87412d4 | |||
| 6e55f442fd | |||
| 09e1b1f261 | |||
| 3e83f8617b | |||
| 1d6472765a | |||
| 2bc85085f5 | |||
| 5c949e2da5 | |||
| 12754bd0aa | |||
| 10bb4eba97 | |||
| 13a734923d | |||
| d5ee8ae0f3 | |||
| 331a9c8dcf | |||
| 808b01c5d7 | |||
| a8abd37e3a | |||
| d1fa76971b | |||
| a9e8394dec | |||
| 9dafd8e8b0 | |||
| 0c1f6969da | |||
| 4214e14ba8 | |||
| 447a31f91e | |||
| 9e21823163 | |||
| aa7e2a3128 | |||
| ecef45c98a | |||
| d165264876 | |||
| dd479d4117 | |||
| 29d91785fb | |||
| 98d1cb1333 | |||
| 2229f547a9 | |||
| 65cc278a59 | |||
| 1555846df2 | |||
| e8c1cadf54 | |||
| d867769025 | |||
| 1d6066d3f0 | |||
| 1d821251a9 | |||
| a0d6905123 | |||
| 4095ed2a68 | |||
| 58e98278d1 | |||
| 90f0bf2555 | |||
| ea2ffdf0c1 | |||
| 9aedd9baad | |||
| 690b52d8c6 | |||
| a72a3d6e5f | |||
| c4c1fd8aab | |||
| 9801ef453d | |||
| d041a62fcd | |||
| 0e6b5ea6d0 | |||
| a2054786f8 | |||
| 5a46fd0a97 | |||
| b8999161a2 | |||
| 224cbbdcaf | |||
| 75ba3a3dc7 | |||
| 4f57fdb43b | |||
| b170e11dd6 | |||
| 6b278309ed | |||
| ea8ecfb28f | |||
| 099bd3bcb8 | |||
| 0aa0406ea6 | |||
| 3cc726320a | |||
| bda151f4e8 | |||
| 6d411853d5 | |||
| dc4d05a2d9 | |||
| 361859f374 | |||
| c60652e39f | |||
| 1db385e716 | |||
| ab0cb7b782 | |||
| fd73832879 | |||
| 7804d83181 | |||
| fe40a5734e | |||
| c0bdf91f6b | |||
| bd46f97121 | |||
| d71e55d7af | |||
| ddd20fe027 | |||
| dca5f9d149 | |||
| be8ce7a2fe | |||
| 6fa0e265fe | |||
| 74e87bd80e | |||
| d01699009d | |||
| 08db192db1 | |||
| 3ea5fed8f6 | |||
| dd9d6cd550 | |||
| f82244f85a | |||
| 896aaad61e | |||
| e5dece5b7b | |||
| 2797525969 | |||
| f0f8a6f01b | |||
| 3bd5cc99aa | |||
| 937fa6f6fa | |||
| 3ffbabd7fc | |||
| c08203cc76 | |||
| 57f045eaf6 | |||
| faa583bb38 | |||
| 98ea2cb06f | |||
| 868d85bbeb | |||
| dbd9d39da9 | |||
| 72cc9d8974 | |||
| ed50c48965 | |||
| e19dbda5ed | |||
| a72cc1bb1c | |||
| f0e4868252 | |||
| 1a30185047 | |||
| c7e59b137c | |||
| 694b22f205 | |||
| 84855d9d51 | |||
| 6bea3b2a55 | |||
| 2ee1adf142 | |||
| 06d7d1ccad | |||
| 2aae6bfc06 | |||
| 6794b1b2dd | |||
| a6582ef835 | |||
| 3e51e4861d | |||
| 0a12cdb3d6 | |||
| 78698dcbba | |||
| 260fb870e1 | |||
| 2b1e7be193 | |||
| 65a76a7fa2 | |||
| 3d554a2434 | |||
| d57ee2677c | |||
| 2f02ccc362 | |||
| 37a31e30c7 | |||
| e7c6271322 | |||
| 3deec75715 | |||
| 18999357a8 | |||
| e6eba645c4 | |||
| f563335fab | |||
| 6102fabfcb | |||
| 87430ccf7b | |||
| ee42016a67 | |||
| 9f5ac6a9ef | |||
| 7f55ad826c | |||
| 2cc65e8783 | |||
| 5ff09e44d9 | |||
| c6cdcf35eb |
@@ -1,4 +1,4 @@
|
|||||||
API_KEY=abcdefghijklmnopqrstuvwxyz
|
ROOT_API_KEY=abcdefghijklmnopqrstuvwxyz
|
||||||
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
|
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
|
||||||
DISABLE_API_KEY_LOGIN=true
|
DISABLE_API_KEY_LOGIN=true
|
||||||
HEADSCALE_CONTAINER=headscale
|
HEADSCALE_CONTAINER=headscale
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
name: Publish Nightly Docker Image
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 8 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish:
|
||||||
|
name: Build and Publish Nightly
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Check out the repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to ghcr.io
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Docker Metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: ghcr.io/${{ github.repository }}
|
||||||
|
tags: |
|
||||||
|
type=edge,branch=main
|
||||||
|
|
||||||
|
- name: Build and push Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
platforms: linux/amd64, linux/arm64
|
||||||
@@ -0,0 +1,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
|
||||||
@@ -1,3 +1,125 @@
|
|||||||
|
### 0.3.7 (November 30, 2024)
|
||||||
|
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
|
||||||
|
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
|
||||||
|
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
|
||||||
|
- Fixed an issue where the copy machine registration command had a typo.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
### 0.1.5 (May 20, 2024)
|
||||||
|
- Robust configuration handling with fallbacks based on the headscale source.
|
||||||
|
- Support for `client_secret_path` on configuration file based OIDC.
|
||||||
|
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
|
||||||
|
- `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works).
|
||||||
|
- Fixed button responders not actually being invoked (should fix the ACL page).
|
||||||
|
|
||||||
|
### 0.1.4 (May 15, 2024)
|
||||||
|
|
||||||
|
- Users can now be created, renamed, and deleted on the users page.
|
||||||
|
- Machines can be dragged between users to change their ownership.
|
||||||
|
- The login page actually respects the `DISABLE_API_KEY_LOGIN` variable.
|
||||||
|
- Implemented some fixes that should stop dialogs from hanging a webpage.
|
||||||
|
- Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues).
|
||||||
|
- Upgrade other dependencies
|
||||||
|
|
||||||
|
### 0.1.3 (May 4, 2024)
|
||||||
|
|
||||||
|
- Switched to a better icon set for the UI.
|
||||||
|
- Support stable scrollbar gutter if supported by the browser.
|
||||||
|
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
|
||||||
|
|
||||||
### 0.1.2 (May 1, 2024)
|
### 0.1.2 (May 1, 2024)
|
||||||
|
|
||||||
- Added support for renaming, expiring, removing, and managing the routes of a machine.
|
- Added support for renaming, expiring, removing, and managing the routes of a machine.
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
RUN npm install -g pnpm
|
RUN npm install -g pnpm
|
||||||
COPY package.json pnpm-lock.yaml ./
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
COPY patches ./patches
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -18,4 +19,4 @@ RUN echo '{"type":"module"}' > /app/package.json
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
CMD [ "node_modules/.bin/remix-serve", "./build/server/index.js" ]
|
CMD [ "node", "./build/headplane/server.js" ]
|
||||||
|
|||||||
@@ -1,7 +1,20 @@
|
|||||||
# Headplane
|
# Headplane
|
||||||
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale)
|
> An advanced UI for [juanfont/headscale](https://github.com/juanfont/headscale)
|
||||||
|
|
||||||

|
<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.
|
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.
|
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
|
- DNS and *safe* Headscale configuration management
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
- If you run Headscale in a Docker container, see the [Advanced Deployment](/docs/Advanced-Integration.md) guide.
|
> For more configuration options, refer to the [Configuration](/docs/Configuration.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 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
|
## Contributing
|
||||||
If you would like to contribute, please install a relatively modern version of Node.js and PNPM.
|
If you would like to contribute, please install a relatively modern version of Node.js and PNPM.
|
||||||
|
|||||||
@@ -1,40 +1,48 @@
|
|||||||
import { ClipboardIcon } from '@heroicons/react/24/outline'
|
import { CopyIcon } from '@primer/octicons-react'
|
||||||
|
|
||||||
import { toast } from './Toaster'
|
import { toast } from './Toaster'
|
||||||
|
|
||||||
type Properties = {
|
interface Props {
|
||||||
readonly name: string;
|
name: string
|
||||||
readonly value: string;
|
value: string
|
||||||
readonly isCopyable?: boolean;
|
isCopyable?: boolean
|
||||||
|
link?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Attribute({ name, value, isCopyable }: Properties) {
|
export default function Attribute({ name, value, link, isCopyable }: Props) {
|
||||||
const canCopy = isCopyable ?? false
|
const canCopy = isCopyable ?? false
|
||||||
return (
|
return (
|
||||||
<dl className='flex gap-1 text-sm w-full'>
|
<dl className="flex gap-1 text-sm w-full">
|
||||||
<dt className='w-1/4 shrink-0 min-w-0 truncate text-gray-700 dark:text-gray-300 py-1'>
|
<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>
|
</dt>
|
||||||
|
|
||||||
{(canCopy ?? false) ? (
|
{canCopy
|
||||||
<button
|
? (
|
||||||
type='button'
|
<button
|
||||||
className='focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md'
|
type="button"
|
||||||
onClick={async () => {
|
className="focus:outline-none flex items-center gap-x-1 truncate hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-md"
|
||||||
await navigator.clipboard.writeText(value)
|
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||||
toast(`Copied ${name}`)
|
onClick={async () => {
|
||||||
}}
|
await navigator.clipboard.writeText(value)
|
||||||
>
|
toast(`Copied ${name}`)
|
||||||
<dd className='min-w-0 truncate px-2 py-1'>
|
}}
|
||||||
|
>
|
||||||
|
<dd className="min-w-0 truncate px-2 py-1">
|
||||||
|
{value}
|
||||||
|
</dd>
|
||||||
|
<CopyIcon className="text-gray-600 dark:text-gray-200 pr-2 w-max h-3" />
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<dd className="min-w-0 truncate px-2 py-1">
|
||||||
{value}
|
{value}
|
||||||
</dd>
|
</dd>
|
||||||
<ClipboardIcon className='text-gray-600 dark:text-gray-200 pr-2 w-max h-4'/>
|
)}
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<dd className='min-w-0 truncate px-2 py-1'>
|
|
||||||
{value}
|
|
||||||
</dd>
|
|
||||||
)}
|
|
||||||
</dl>
|
</dl>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,37 @@
|
|||||||
import clsx from 'clsx'
|
import { type Dispatch, type SetStateAction } from 'react'
|
||||||
import { type ButtonHTMLAttributes, type DetailedHTMLProps } from 'react'
|
import { Button as AriaButton } from 'react-aria-components'
|
||||||
|
|
||||||
type Properties = {
|
import { cn } from '~/utils/cn'
|
||||||
readonly variant?: 'emphasized' | 'normal' | 'destructive';
|
|
||||||
} & DetailedHTMLProps<ButtonHTMLAttributes<HTMLButtonElement>, HTMLButtonElement>
|
|
||||||
|
|
||||||
export default function Action(properties: Properties) {
|
type ButtonProperties = Parameters<typeof AriaButton>[0] & {
|
||||||
|
readonly control?: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||||
|
readonly variant?: 'heavy' | 'light'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button(properties: ButtonProperties) {
|
||||||
return (
|
return (
|
||||||
<button
|
<AriaButton
|
||||||
type='button'
|
|
||||||
{...properties}
|
{...properties}
|
||||||
className={clsx(
|
className={cn(
|
||||||
'focus:outline-none focus:ring focus:ring-1',
|
'w-fit text-sm rounded-lg px-4 py-2',
|
||||||
'focus:ring-blue-500 dark:focus:ring-blue-300',
|
properties.variant === 'heavy'
|
||||||
|
? 'bg-main-700 dark:bg-main-800'
|
||||||
|
: 'bg-main-200 dark:bg-main-700/30',
|
||||||
|
properties.variant === 'heavy'
|
||||||
|
? 'hover:bg-main-800 dark:hover:bg-main-700'
|
||||||
|
: 'hover:bg-main-300 dark:hover:bg-main-600/30',
|
||||||
|
properties.variant === 'heavy'
|
||||||
|
? 'text-white'
|
||||||
|
: 'text-ui-700 dark:text-ui-300',
|
||||||
|
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||||
properties.className,
|
properties.className,
|
||||||
properties.disabled && 'opacity-50 cursor-not-allowed',
|
|
||||||
properties.variant === 'destructive' ? 'text-red-700 dark:text-red-500' : '',
|
|
||||||
properties.variant === 'emphasized' ? 'rounded-lg px-4 py-2 bg-gray-800 dark:bg-gray-700 text-white' : '',
|
|
||||||
!properties.variant || properties.variant === 'normal' ? 'text-blue-700 dark:text-blue-400' : ''
|
|
||||||
)}
|
)}
|
||||||
>
|
// If control is passed, set the state value
|
||||||
{properties.children}
|
onPress={properties.control
|
||||||
</button>
|
? () => {
|
||||||
|
properties.control?.[1](true)
|
||||||
|
}
|
||||||
|
: properties.onPress}
|
||||||
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,47 @@
|
|||||||
import clsx from 'clsx'
|
|
||||||
import { type HTMLProps } from 'react'
|
import { type HTMLProps } from 'react'
|
||||||
|
import { Heading as AriaHeading } from 'react-aria-components'
|
||||||
|
|
||||||
type Properties = HTMLProps<HTMLDivElement>
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
export default function Card(properties: Properties) {
|
function Title(properties: Parameters<typeof AriaHeading>[0]) {
|
||||||
|
return (
|
||||||
|
<AriaHeading
|
||||||
|
{...properties}
|
||||||
|
slot='title'
|
||||||
|
className={cn(
|
||||||
|
'text-lg font-semibold leading-6 mb-5',
|
||||||
|
properties.className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Text(properties: React.HTMLProps<HTMLParagraphElement>) {
|
||||||
|
return (
|
||||||
|
<p
|
||||||
|
{...properties}
|
||||||
|
className={cn(
|
||||||
|
'text-base leading-6 my-0',
|
||||||
|
properties.className
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Properties = HTMLProps<HTMLDivElement> & {
|
||||||
|
variant?: 'raised' | 'flat';
|
||||||
|
}
|
||||||
|
|
||||||
|
function Card(properties: Properties) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...properties}
|
{...properties}
|
||||||
className={clsx(
|
className={cn(
|
||||||
'p-4 md:p-6 border dark:border-zinc-700 rounded-lg',
|
'w-full max-w-md overflow-hidden rounded-xl p-4',
|
||||||
|
properties.variant === 'flat'
|
||||||
|
? 'bg-transparent shadow-none'
|
||||||
|
: 'bg-ui-50 dark:bg-ui-900 shadow-sm',
|
||||||
|
'border border-ui-200 dark:border-ui-700',
|
||||||
properties.className
|
properties.className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -16,3 +49,5 @@ export default function Card(properties: Properties) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default Object.assign(Card, { Title, Text })
|
||||||
|
|||||||
@@ -1,12 +1,45 @@
|
|||||||
import clsx from 'clsx'
|
import { useState, HTMLProps } from 'react'
|
||||||
import { type 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 (
|
return (
|
||||||
<code className={clsx('bg-gray-100 dark:bg-zinc-700 p-0.5 rounded-md', properties.className)}>
|
<>
|
||||||
{properties.children}
|
<code className={cn(
|
||||||
</code>
|
'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.join(''))
|
||||||
|
toast('Copied to clipboard')
|
||||||
|
setIsCopied(true)
|
||||||
|
setTimeout(() => setIsCopied(false), 1000)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isCopied ?
|
||||||
|
<CheckIcon className="h-3 w-3" /> :
|
||||||
|
<CopyIcon className="h-3 w-3" />
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function Button(properties: ButtonProperties) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'w-fit text-sm rounded-lg px-4 py-2',
|
'w-fit text-sm rounded-lg px-4 py-2',
|
||||||
'bg-main-700 dark:bg-main-800 text-white',
|
'bg-main-700 dark:bg-main-800 text-white',
|
||||||
|
'hover:bg-main-800 dark:hover:bg-main-700',
|
||||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||||
properties.className
|
properties.className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,52 +1,46 @@
|
|||||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'
|
import { AlertIcon } from '@primer/octicons-react'
|
||||||
import { isRouteErrorResponse, useRouteError } from '@remix-run/react'
|
import { isRouteErrorResponse, useRouteError } from '@remix-run/react'
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
|
import Card from './Card'
|
||||||
import Code from './Code'
|
import Code from './Code'
|
||||||
import Dialog from './Dialog'
|
|
||||||
|
|
||||||
type Properties = {
|
type Properties = {
|
||||||
readonly type?: 'full' | 'embedded';
|
readonly type?: 'full' | 'embedded';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorPopup({ type = 'full' }: Properties) {
|
export function ErrorPopup({ type = 'full' }: Properties) {
|
||||||
// eslint-disable-next-line react/hook-use-state
|
|
||||||
const open = useState(true)
|
|
||||||
|
|
||||||
const error = useRouteError()
|
const error = useRouteError()
|
||||||
const routing = isRouteErrorResponse(error)
|
const routing = isRouteErrorResponse(error)
|
||||||
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
|
const message = (error instanceof Error ? error.message : 'An unexpected error occurred')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<div
|
||||||
<Dialog.Panel
|
className={cn(
|
||||||
className={cn(
|
'flex items-center justify-center',
|
||||||
type === 'embedded' ? 'pointer-events-none bg-transparent dark:bg-transparent' : '',
|
type === 'embedded'
|
||||||
)}
|
? 'pointer-events-none mt-24'
|
||||||
control={open}
|
: 'fixed inset-0 h-screen w-screen z-50'
|
||||||
>
|
)}
|
||||||
{() => (
|
>
|
||||||
<>
|
<Card>
|
||||||
<div className='flex items-center justify-between'>
|
<div className='flex items-center justify-between'>
|
||||||
<Dialog.Title className='text-3xl mb-0'>
|
<Card.Title className='text-3xl mb-0'>
|
||||||
{routing ? error.status : 'Error'}
|
{routing ? error.status : 'Error'}
|
||||||
</Dialog.Title>
|
</Card.Title>
|
||||||
<ExclamationTriangleIcon className='w-12 h-12 text-red-500'/>
|
<AlertIcon className='w-12 h-12 text-red-500'/>
|
||||||
</div>
|
</div>
|
||||||
<Dialog.Text className='mt-4 text-lg'>
|
<Card.Text className='mt-4 text-lg'>
|
||||||
{routing ? (
|
{routing ? (
|
||||||
error.statusText
|
error.statusText
|
||||||
) : (
|
) : (
|
||||||
<Code>
|
<Code>
|
||||||
{message}
|
{message}
|
||||||
</Code>
|
</Code>
|
||||||
)}
|
)}
|
||||||
</Dialog.Text>
|
</Card.Text>
|
||||||
</>
|
</Card>
|
||||||
)}
|
</div>
|
||||||
</Dialog.Panel>
|
|
||||||
</Dialog>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { GearIcon, GlobeIcon, LockIcon, PaperAirplaneIcon, PeopleIcon, PersonIcon, ServerIcon } from '@primer/octicons-react'
|
||||||
|
import { Form } from '@remix-run/react'
|
||||||
|
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
import { HeadplaneContext } from '~/utils/config/headplane'
|
||||||
|
import { type SessionData } from '~/utils/sessions'
|
||||||
|
|
||||||
|
import Menu from './Menu'
|
||||||
|
import TabLink from './TabLink'
|
||||||
|
|
||||||
|
interface Properties {
|
||||||
|
readonly data?: {
|
||||||
|
config: HeadplaneContext['config']
|
||||||
|
user?: SessionData['user']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LinkProperties {
|
||||||
|
readonly href: string
|
||||||
|
readonly text: string
|
||||||
|
readonly isMenu?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function Link({ href, text, isMenu }: LinkProperties) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={cn(
|
||||||
|
!isMenu && 'text-ui-300 hover:text-ui-50 hover:underline hidden sm:block',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Header({ data }: Properties) {
|
||||||
|
return (
|
||||||
|
<header className="bg-main-700 dark:bg-main-800 text-ui-50">
|
||||||
|
<div className="container flex items-center justify-between py-4">
|
||||||
|
<div className="flex items-center gap-x-2">
|
||||||
|
<PaperAirplaneIcon className="w-6 h-6" />
|
||||||
|
<h1 className="text-2xl">Headplane</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-x-4">
|
||||||
|
<Link href="https://tailscale.com/download" text="Download" />
|
||||||
|
<Link href="https://github.com/tale/headplane" text="GitHub" />
|
||||||
|
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
||||||
|
{data?.user
|
||||||
|
? (
|
||||||
|
<Menu>
|
||||||
|
<Menu.Button className={cn(
|
||||||
|
'rounded-full h-9 w-9',
|
||||||
|
'border border-main-600 dark:border-main-700',
|
||||||
|
'hover:bg-main-600 dark:hover:bg-main-700',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PersonIcon className="h-5 w-5 mt-0.5" />
|
||||||
|
</Menu.Button>
|
||||||
|
<Menu.Items>
|
||||||
|
<Menu.Item className="text-right">
|
||||||
|
<p className="font-bold">{data.user.name}</p>
|
||||||
|
<p>{data.user.email}</p>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item className="text-right sm:hidden">
|
||||||
|
<Link
|
||||||
|
isMenu
|
||||||
|
href="https://tailscale.com/download"
|
||||||
|
text="Download"
|
||||||
|
/>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item className="text-right sm:hidden">
|
||||||
|
<Link
|
||||||
|
isMenu
|
||||||
|
href="https://github.com/tale/headplane"
|
||||||
|
text="GitHub"
|
||||||
|
/>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item className="text-right sm:hidden">
|
||||||
|
<Link
|
||||||
|
isMenu
|
||||||
|
href="https://github.com/juanfont/headscale"
|
||||||
|
text="Headscale"
|
||||||
|
/>
|
||||||
|
</Menu.Item>
|
||||||
|
<Menu.Item className="text-red-500 dark:text-red-400">
|
||||||
|
<Form method="POST" action="/logout">
|
||||||
|
<button type="submit" className="w-full text-right">
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</Form>
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Items>
|
||||||
|
</Menu>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="container flex items-center gap-x-4 overflow-x-auto">
|
||||||
|
<TabLink to="/machines" name="Machines" icon={<ServerIcon className="w-4 h-4" />} />
|
||||||
|
<TabLink to="/users" name="Users" icon={<PeopleIcon className="w-4 h-4" />} />
|
||||||
|
<TabLink to="/acls" name="Access Control" icon={<LockIcon className="w-4 h-4" />} />
|
||||||
|
{data?.config.read
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<TabLink to="/dns" name="DNS" icon={<GlobeIcon className="w-4 h-4" />} />
|
||||||
|
<TabLink to="/settings" name="Settings" icon={<GearIcon className="w-4 h-4" />} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import clsx from 'clsx'
|
|
||||||
import { type DetailedHTMLProps, type InputHTMLAttributes } from 'react'
|
|
||||||
|
|
||||||
type Properties = {
|
|
||||||
readonly variant?: 'embedded' | 'normal';
|
|
||||||
} & DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>
|
|
||||||
|
|
||||||
export default function Input(properties: Properties) {
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
{...properties}
|
|
||||||
className={clsx(
|
|
||||||
'block w-full dark:text-gray-300',
|
|
||||||
'border-gray-300 dark:border-zinc-700',
|
|
||||||
'focus:outline-none focus:ring',
|
|
||||||
'focus:ring-blue-500 dark:focus:ring-blue-300',
|
|
||||||
properties.variant === 'embedded' ? 'bg-transparent' : 'dark:bg-zinc-800',
|
|
||||||
properties.variant === 'embedded' ? 'p-0' : 'px-2.5 py-1.5',
|
|
||||||
properties.variant === 'embedded' ? 'border-none' : 'border',
|
|
||||||
properties.variant === 'embedded' ? 'focus:ring-0' : 'focus:ring-1',
|
|
||||||
properties.variant === 'embedded' ? 'rounded-none' : 'rounded-lg',
|
|
||||||
properties.className
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { LinkExternalIcon } from '@primer/octicons-react'
|
||||||
|
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
to: string
|
||||||
|
name: string
|
||||||
|
children: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Link({ to, name: alt, children, className }: Props) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={to}
|
||||||
|
aria-label={alt}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-x-1',
|
||||||
|
'text-blue-500 hover:text-blue-700',
|
||||||
|
'dark:text-blue-400 dark:hover:text-blue-300',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<LinkExternalIcon className="h-3 w-3" />
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,15 +1,22 @@
|
|||||||
import { InformationCircleIcon } from '@heroicons/react/24/outline'
|
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 (
|
return (
|
||||||
<div className={clsx(
|
<div className={cn(
|
||||||
'p-4 rounded-md w-fit flex items-center gap-3',
|
'p-4 rounded-md w-full flex items-center gap-3',
|
||||||
'bg-slate-400 dark:bg-slate-700'
|
'bg-ui-200 dark:bg-ui-800',
|
||||||
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<InformationCircleIcon className='h-6 w-6 text-white'/>
|
<InfoIcon className="h-6 w-6 text-ui-700 dark:text-ui-200" />
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
@@ -15,23 +15,17 @@ export default function Switch(properties: SwitchProperties) {
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-[26px] w-[44px] shrink-0 cursor-default',
|
'flex h-[26px] w-[44px] p-[4px] shrink-0',
|
||||||
'rounded-full shadow-inner bg-clip-padding',
|
'rounded-full outline-none group-focus-visible:ring-2',
|
||||||
'border border-solid border-white/30 p-[3px]',
|
'bg-main-600/50 dark:bg-main-600/20 group-selected:bg-main-700',
|
||||||
'box-border transition duration-100 ease-in-out',
|
|
||||||
'outline-none group-focus-visible:ring-2 ring-black',
|
|
||||||
|
|
||||||
'bg-main-700 dark:bg-main-800',
|
|
||||||
'group-pressed:bg-main-800 dark:group-pressed:bg-main-900',
|
|
||||||
'group-selected:bg-main-900 group-selected:group-pressed:bg-main-900',
|
|
||||||
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
properties.isDisabled && 'opacity-50 cursor-not-allowed',
|
||||||
properties.className
|
properties.className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'h-[18px] w-[18px] transform rounded-full',
|
'h-[18px] w-[18px] transform rounded-full',
|
||||||
'bg-white shadow transition duration-100',
|
'bg-white transition duration-100 ease-in-out',
|
||||||
'ease-in-out translate-x-0 group-selected:translate-x-[100%]'
|
'translate-x-0 group-selected:translate-x-[100%]'
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { NavLink } from '@remix-run/react'
|
import { NavLink } from '@remix-run/react'
|
||||||
import clsx from 'clsx'
|
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
type Properties = {
|
type Properties = {
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly to: string;
|
readonly to: string;
|
||||||
@@ -12,12 +13,20 @@ export default function TabLink({ name, to, icon }: Properties) {
|
|||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
to={to}
|
to={to}
|
||||||
className={({ isActive, isPending }) => clsx(
|
prefetch='intent'
|
||||||
'flex items-center gap-x-2 p-2 border-b-2 text-md',
|
className={({ isActive }) => cn(
|
||||||
|
'border-b-2 py-1.5',
|
||||||
isActive ? 'border-white' : 'border-transparent'
|
isActive ? 'border-white' : 'border-transparent'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{icon} {name}
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-x-2 px-2.5 py-1.5 text-md text-nowrap',
|
||||||
|
'hover:bg-ui-100/5 dark:hover:bg-ui-900/40 rounded-md'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{icon} {name}
|
||||||
|
</div>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { cn } from '~/utils/cn'
|
|||||||
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
|
type TextFieldProperties = Parameters<typeof AriaTextField>[0] & {
|
||||||
readonly label: string;
|
readonly label: string;
|
||||||
readonly placeholder: string;
|
readonly placeholder: string;
|
||||||
readonly state: [string, Dispatch<SetStateAction<string>>];
|
readonly state?: [string, Dispatch<SetStateAction<string>>];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TextField(properties: TextFieldProperties) {
|
export default function TextField(properties: TextFieldProperties) {
|
||||||
@@ -21,7 +21,7 @@ export default function TextField(properties: TextFieldProperties) {
|
|||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
placeholder={properties.placeholder}
|
placeholder={properties.placeholder}
|
||||||
value={properties.state[0]}
|
value={properties.state?.[0]}
|
||||||
name={properties.name}
|
name={properties.name}
|
||||||
className={cn(
|
className={cn(
|
||||||
'block px-2.5 py-1.5 w-full rounded-lg my-1',
|
'block px-2.5 py-1.5 w-full rounded-lg my-1',
|
||||||
@@ -30,7 +30,7 @@ export default function TextField(properties: TextFieldProperties) {
|
|||||||
properties.className
|
properties.className
|
||||||
)}
|
)}
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
properties.state[1](event.target.value)
|
properties.state?.[1](event.target.value)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</AriaTextField>
|
</AriaTextField>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { XMarkIcon } from '@heroicons/react/24/outline'
|
import { XIcon } from '@primer/octicons-react'
|
||||||
import { type AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
|
import { AriaToastProps, useToast, useToastRegion } from '@react-aria/toast'
|
||||||
import { ToastQueue, type ToastState, useToastQueue } from '@react-stately/toast'
|
import { ToastQueue, ToastState, useToastQueue } from '@react-stately/toast'
|
||||||
import { type ReactNode, useRef } from 'react'
|
import { ReactNode, useRef } from 'react'
|
||||||
import { Button } from 'react-aria-components'
|
import { Button } from 'react-aria-components'
|
||||||
import { createPortal } from 'react-dom'
|
import { createPortal } from 'react-dom'
|
||||||
import { ClientOnly } from 'remix-utils/client-only'
|
import { ClientOnly } from 'remix-utils/client-only'
|
||||||
@@ -14,6 +14,8 @@ type ToastProperties = AriaToastProps<ReactNode> & {
|
|||||||
|
|
||||||
function Toast({ state, ...properties }: ToastProperties) {
|
function Toast({ state, ...properties }: ToastProperties) {
|
||||||
const reference = useRef(null)
|
const reference = useRef(null)
|
||||||
|
|
||||||
|
// @ts-expect-error: RefObject doesn't map to FocusableElement?
|
||||||
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
|
const { toastProps, titleProps, closeButtonProps } = useToast(properties, state, reference)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,7 +37,7 @@ function Toast({ state, ...properties }: ToastProperties) {
|
|||||||
'hover:bg-main-600 dark:hover:bg-main-700'
|
'hover:bg-main-600 dark:hover:bg-main-700'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<XMarkIcon className='w-4 h-4'/>
|
<XIcon className='w-4 h-4'/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -52,11 +54,15 @@ export function toast(text: string) {
|
|||||||
export function Toaster() {
|
export function Toaster() {
|
||||||
const reference = useRef(null)
|
const reference = useRef(null)
|
||||||
const state = useToastQueue(toasts)
|
const state = useToastQueue(toasts)
|
||||||
|
|
||||||
|
// @ts-expect-error: React 19 has weird types for Portal vs Node
|
||||||
const { regionProps } = useToastRegion({}, state, reference)
|
const { regionProps } = useToastRegion({}, state, reference)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
{() => createPortal(
|
{
|
||||||
|
// @ts-expect-error: Portal doesn't match Node in React 19 yet
|
||||||
|
() => createPortal(
|
||||||
state.visibleToasts.length >= 0 ? (
|
state.visibleToasts.length >= 0 ? (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
Button as AriaButton,
|
||||||
|
Tooltip as AriaTooltip,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from 'react-aria-components'
|
||||||
|
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function Tooltip({ children }: Props) {
|
||||||
|
return (
|
||||||
|
<TooltipTrigger delay={0}>
|
||||||
|
{children}
|
||||||
|
</TooltipTrigger>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Button(props: Parameters<typeof AriaButton>[0]) {
|
||||||
|
return (
|
||||||
|
<AriaButton {...props} />
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Body({ children, className }: Props) {
|
||||||
|
return (
|
||||||
|
<AriaTooltip className={cn(
|
||||||
|
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
||||||
|
'bg-white dark:bg-ui-900 drop-shadow-sm',
|
||||||
|
'border border-gray-200 dark:border-zinc-700',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AriaTooltip>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Object.assign(Tooltip, { Button, Body })
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
/**
|
|
||||||
* By default, Remix will handle hydrating your app on the client for you.
|
|
||||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
|
||||||
* For more information, see https://remix.run/file-conventions/entry.client
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { RemixBrowser } from '@remix-run/react'
|
|
||||||
import { startTransition, StrictMode } from 'react'
|
|
||||||
import { hydrateRoot } from 'react-dom/client'
|
|
||||||
|
|
||||||
startTransition(() => {
|
|
||||||
hydrateRoot(
|
|
||||||
document,
|
|
||||||
<StrictMode>
|
|
||||||
<RemixBrowser/>
|
|
||||||
</StrictMode>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
@@ -1,137 +1,62 @@
|
|||||||
/**
|
|
||||||
* By default, Remix will handle generating the HTTP Response for you.
|
|
||||||
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
|
||||||
* For more information, see https://remix.run/file-conventions/entry.server
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { PassThrough } from 'node:stream'
|
import { PassThrough } from 'node:stream'
|
||||||
|
|
||||||
import type { EntryContext } from '@remix-run/node'
|
import type { AppLoadContext, EntryContext } from '@remix-run/node'
|
||||||
import { createReadableStreamFromReadable } from '@remix-run/node'
|
import { createReadableStreamFromReadable } from '@remix-run/node'
|
||||||
import { RemixServer } from '@remix-run/react'
|
import { RemixServer } from '@remix-run/react'
|
||||||
import { isbot } from 'isbot'
|
import { isbot } from 'isbot'
|
||||||
import { renderToPipeableStream } from 'react-dom/server'
|
import { renderToPipeableStream } from 'react-dom/server'
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
import { loadContext } from './utils/config/headplane'
|
||||||
const ABORT_DELAY = 5000
|
|
||||||
|
|
||||||
export default async function handleRequest(
|
await loadContext()
|
||||||
|
|
||||||
|
export default function handleRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
responseStatusCode: number,
|
responseStatusCode: number,
|
||||||
responseHeaders: Headers,
|
responseHeaders: Headers,
|
||||||
remixContext: EntryContext
|
remixContext: EntryContext,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
_loadContext: AppLoadContext,
|
||||||
) {
|
) {
|
||||||
return isbot(request.headers.get('user-agent') ?? '')
|
const ua = request.headers.get('user-agent')
|
||||||
? handleBotRequest(
|
const isBot = ua ? isbot(ua) : false
|
||||||
request,
|
|
||||||
responseStatusCode,
|
|
||||||
responseHeaders,
|
|
||||||
remixContext
|
|
||||||
)
|
|
||||||
: handleBrowserRequest(
|
|
||||||
request,
|
|
||||||
responseStatusCode,
|
|
||||||
responseHeaders,
|
|
||||||
remixContext
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBotRequest(
|
|
||||||
request: Request,
|
|
||||||
responseStatusCode: number,
|
|
||||||
responseHeaders: Headers,
|
|
||||||
remixContext: EntryContext
|
|
||||||
) {
|
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let shellRendered = false
|
let shellRendered = false
|
||||||
const { pipe, abort } = renderToPipeableStream(
|
const { pipe, abort } = renderToPipeableStream(
|
||||||
<RemixServer
|
<RemixServer
|
||||||
context={remixContext}
|
context={remixContext}
|
||||||
url={request.url}
|
url={request.url}
|
||||||
abortDelay={ABORT_DELAY}
|
abortDelay={5000}
|
||||||
/>,
|
/>,
|
||||||
{
|
{
|
||||||
onAllReady() {
|
[isBot ? 'onAllReady' : 'onShellReady']() {
|
||||||
shellRendered = true
|
shellRendered = true
|
||||||
const body = new PassThrough()
|
const body = new PassThrough()
|
||||||
const stream = createReadableStreamFromReadable(body)
|
const stream = createReadableStreamFromReadable(body)
|
||||||
|
|
||||||
responseHeaders.set('Content-Type', 'text/html')
|
responseHeaders.set('Content-Type', 'text/html')
|
||||||
|
|
||||||
resolve(
|
resolve(
|
||||||
new Response(stream, {
|
new Response(stream, {
|
||||||
headers: responseHeaders,
|
headers: responseHeaders,
|
||||||
status: responseStatusCode
|
status: responseStatusCode,
|
||||||
})
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
pipe(body)
|
pipe(body)
|
||||||
},
|
},
|
||||||
onShellError(error: unknown) {
|
onShellError(error: unknown) {
|
||||||
reject(error)
|
reject(error as Error)
|
||||||
},
|
},
|
||||||
onError(error: unknown) {
|
onError(error: unknown) {
|
||||||
responseStatusCode = 500
|
responseStatusCode = 500
|
||||||
// Log streaming rendering errors from inside the shell. Don't log
|
|
||||||
// errors encountered during initial shell rendering since they'll
|
|
||||||
// reject and get logged in handleDocumentRequest.
|
|
||||||
if (shellRendered) {
|
if (shellRendered) {
|
||||||
console.error(error)
|
console.error(error)
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
setTimeout(abort, ABORT_DELAY)
|
setTimeout(abort, 5000)
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleBrowserRequest(
|
|
||||||
request: Request,
|
|
||||||
responseStatusCode: number,
|
|
||||||
responseHeaders: Headers,
|
|
||||||
remixContext: EntryContext
|
|
||||||
) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let shellRendered = false
|
|
||||||
const { pipe, abort } = renderToPipeableStream(
|
|
||||||
<RemixServer
|
|
||||||
context={remixContext}
|
|
||||||
url={request.url}
|
|
||||||
abortDelay={ABORT_DELAY}
|
|
||||||
/>,
|
|
||||||
{
|
|
||||||
onShellReady() {
|
|
||||||
shellRendered = true
|
|
||||||
const body = new PassThrough()
|
|
||||||
const stream = createReadableStreamFromReadable(body)
|
|
||||||
|
|
||||||
responseHeaders.set('Content-Type', 'text/html')
|
|
||||||
|
|
||||||
resolve(
|
|
||||||
new Response(stream, {
|
|
||||||
headers: responseHeaders,
|
|
||||||
status: responseStatusCode
|
|
||||||
})
|
|
||||||
)
|
|
||||||
|
|
||||||
pipe(body)
|
|
||||||
},
|
|
||||||
onShellError(error: unknown) {
|
|
||||||
reject(error)
|
|
||||||
},
|
|
||||||
onError(error: unknown) {
|
|
||||||
responseStatusCode = 500
|
|
||||||
// Log streaming rendering errors from inside the shell. Don't log
|
|
||||||
// errors encountered during initial shell rendering since they'll
|
|
||||||
// reject and get logged in handleDocumentRequest.
|
|
||||||
if (shellRendered) {
|
|
||||||
console.error(error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
setTimeout(abort, ABORT_DELAY)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { access, constants } from 'node:fs/promises'
|
||||||
|
import { setTimeout } from 'node:timers/promises'
|
||||||
|
|
||||||
|
import { Client } from 'undici'
|
||||||
|
|
||||||
|
import { HeadscaleError, pull } from '~/utils/headscale'
|
||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
|
import { createIntegration } from './integration'
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
client: Client | undefined
|
||||||
|
container: string | undefined
|
||||||
|
maxAttempts: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createIntegration<Context>({
|
||||||
|
name: 'Docker',
|
||||||
|
context: {
|
||||||
|
client: undefined,
|
||||||
|
container: undefined,
|
||||||
|
maxAttempts: 10,
|
||||||
|
},
|
||||||
|
isAvailable: async (context) => {
|
||||||
|
// Check for the HEADSCALE_CONTAINER environment variable first
|
||||||
|
// to avoid unnecessary fetching of the Docker socket
|
||||||
|
log.debug('INTG', 'Checking Docker integration availability')
|
||||||
|
context.container = process.env.HEADSCALE_CONTAINER
|
||||||
|
?.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
|
if (!context.container || context.container.length === 0) {
|
||||||
|
log.error('INTG', 'Missing HEADSCALE_CONTAINER variable')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('INTG', 'Using container: %s', context.container)
|
||||||
|
const path = process.env.DOCKER_SOCK ?? 'unix:///var/run/docker.sock'
|
||||||
|
let url: URL | undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
url = new URL(path)
|
||||||
|
} catch {
|
||||||
|
log.error('INTG', 'Invalid Docker socket path: %s', path)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
|
||||||
|
log.error('INTG', 'Invalid Docker socket protocol: %s',
|
||||||
|
url.protocol,
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// The API is available as an HTTP endpoint and this
|
||||||
|
// will simplify the fetching logic in undici
|
||||||
|
if (url.protocol === 'tcp:') {
|
||||||
|
// Apparently setting url.protocol doesn't work anymore?
|
||||||
|
const fetchU = url.href.replace(url.protocol, 'http:')
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.info('INTG', 'Checking API: %s', fetchU)
|
||||||
|
await fetch(new URL('/v1.30/version', fetchU).href)
|
||||||
|
} catch (error) {
|
||||||
|
log.debug('INTG', 'Failed to connect to Docker API', error)
|
||||||
|
log.error('INTG', 'Failed to connect to Docker API')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
context.client = new Client(fetchU)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the socket is accessible
|
||||||
|
if (url.protocol === 'unix:') {
|
||||||
|
try {
|
||||||
|
log.info('INTG', 'Checking socket: %s',
|
||||||
|
url.pathname,
|
||||||
|
)
|
||||||
|
await access(url.pathname, constants.R_OK)
|
||||||
|
} catch (error) {
|
||||||
|
log.debug('INTG', 'Failed to access Docker socket: %s', error)
|
||||||
|
log.error('INTG', 'Failed to access Docker socket: %s',
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
context.client = new Client('http://localhost', {
|
||||||
|
socketPath: url.pathname,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return context.client !== undefined
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfigChange: async (context) => {
|
||||||
|
if (!context.client || !context.container) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('INTG', 'Restarting Headscale via Docker')
|
||||||
|
|
||||||
|
let attempts = 0
|
||||||
|
while (attempts <= context.maxAttempts) {
|
||||||
|
log.debug(
|
||||||
|
'INTG', 'Restarting container: %s (attempt %d)',
|
||||||
|
context.container,
|
||||||
|
attempts,
|
||||||
|
)
|
||||||
|
|
||||||
|
const response = await context.client.request({
|
||||||
|
method: 'POST',
|
||||||
|
path: `/v1.30/containers/${context.container}/restart`,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.statusCode !== 204) {
|
||||||
|
if (attempts < context.maxAttempts) {
|
||||||
|
attempts++
|
||||||
|
await setTimeout(1000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const stringCode = response.statusCode.toString()
|
||||||
|
const body = await response.body.text()
|
||||||
|
throw new Error(`API request failed: ${stringCode} ${body}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts = 0
|
||||||
|
while (attempts <= context.maxAttempts) {
|
||||||
|
try {
|
||||||
|
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts)
|
||||||
|
await pull('v1', '')
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HeadscaleError && error.status === 401) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error instanceof HeadscaleError && error.status === 404) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempts < context.maxAttempts) {
|
||||||
|
attempts++
|
||||||
|
await setTimeout(1000)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Missed restart deadline for ${context.container}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
|
import dockerIntegration from './docker'
|
||||||
|
import { IntegrationFactory } from './integration'
|
||||||
|
import kubernetesIntegration from './kubernetes'
|
||||||
|
import procIntegration from './proc'
|
||||||
|
|
||||||
|
export * from './integration'
|
||||||
|
|
||||||
|
export async function loadIntegration() {
|
||||||
|
let integration = process.env.HEADSCALE_INTEGRATION
|
||||||
|
?.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
|
||||||
|
// Old HEADSCALE_CONTAINER variable upgrade path
|
||||||
|
// This ensures that when people upgrade from older versions of Headplane
|
||||||
|
// they don't explicitly need to define the new HEADSCALE_INTEGRATION
|
||||||
|
// variable that is needed to configure docker
|
||||||
|
if (!integration && process.env.HEADSCALE_CONTAINER) {
|
||||||
|
integration = 'docker'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!integration) {
|
||||||
|
log.info('INTG', 'No integration set with HEADSCALE_INTEGRATION')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let integrationFactory: IntegrationFactory | undefined
|
||||||
|
switch (integration.toLowerCase().trim()) {
|
||||||
|
case 'docker': {
|
||||||
|
integrationFactory = dockerIntegration
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'proc':
|
||||||
|
case 'native':
|
||||||
|
case 'linux': {
|
||||||
|
integrationFactory = procIntegration
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'kubernetes':
|
||||||
|
case 'k8s': {
|
||||||
|
integrationFactory = kubernetesIntegration
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
log.error('INTG', 'Unknown integration: %s', integration)
|
||||||
|
throw new Error(`Unknown integration: ${integration}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('INTG', 'Loading integration: %s', integration)
|
||||||
|
try {
|
||||||
|
const res = await integrationFactory.isAvailable(
|
||||||
|
integrationFactory.context,
|
||||||
|
)
|
||||||
|
if (!res) {
|
||||||
|
log.error('INTG', 'Integration %s is not available',
|
||||||
|
integration,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('INTG', 'Failed to load integration %s: %s',
|
||||||
|
integration,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('INTG', 'Loaded integration: %s', integration)
|
||||||
|
return integrationFactory
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
export interface IntegrationFactory<T = any> {
|
||||||
|
name: string
|
||||||
|
context: T
|
||||||
|
isAvailable: (context: T) => Promise<boolean> | boolean
|
||||||
|
onConfigChange?: (context: T) => Promise<void> | void
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createIntegration<T>(
|
||||||
|
options: IntegrationFactory<T>,
|
||||||
|
) {
|
||||||
|
return options
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
|
import { platform } from 'node:os'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import { kill } from 'node:process'
|
||||||
|
|
||||||
|
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node'
|
||||||
|
|
||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
|
import { createIntegration } from './integration'
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
pid: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createIntegration<Context>({
|
||||||
|
name: 'Kubernetes (k8s)',
|
||||||
|
context: {
|
||||||
|
pid: undefined,
|
||||||
|
},
|
||||||
|
isAvailable: async (context) => {
|
||||||
|
if (platform() !== 'linux') {
|
||||||
|
log.error('INTG', 'Kubernetes is only available on Linux')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const svcRoot = Config.SERVICEACCOUNT_ROOT
|
||||||
|
try {
|
||||||
|
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot)
|
||||||
|
const files = await readdir(svcRoot)
|
||||||
|
if (files.length === 0) {
|
||||||
|
log.error('INTG', 'Kubernetes service account not found')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const mappedFiles = new Set(files.map(file => join(svcRoot, file)))
|
||||||
|
const expectedFiles = [
|
||||||
|
Config.SERVICEACCOUNT_CA_PATH,
|
||||||
|
Config.SERVICEACCOUNT_TOKEN_PATH,
|
||||||
|
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||||
|
]
|
||||||
|
|
||||||
|
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '))
|
||||||
|
if (!expectedFiles.every(file => mappedFiles.has(file))) {
|
||||||
|
log.error('INTG', 'Malformed Kubernetes service account')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('INTG', 'Failed to access %s: %s', svcRoot, error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot)
|
||||||
|
const namespace = await readFile(
|
||||||
|
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
// Some very ugly nesting but it's necessary
|
||||||
|
if (process.env.HEADSCALE_INTEGRATION_UNSTRICT === 'true') {
|
||||||
|
log.warn('INTG', 'Skipping strict Pod status check')
|
||||||
|
} else {
|
||||||
|
const pod = process.env.POD_NAME
|
||||||
|
if (!pod) {
|
||||||
|
log.error('INTG', 'Missing POD_NAME variable')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pod.trim().length === 0) {
|
||||||
|
log.error('INTG', 'Pod name is empty')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Checking Kubernetes pod %s in namespace %s',
|
||||||
|
pod,
|
||||||
|
namespace,
|
||||||
|
)
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.debug('INTG', 'Attempgin to get cluster KubeConfig')
|
||||||
|
const kc = new KubeConfig()
|
||||||
|
kc.loadFromCluster()
|
||||||
|
|
||||||
|
const cluster = kc.getCurrentCluster()
|
||||||
|
if (!cluster) {
|
||||||
|
log.error('INTG', 'Malformed kubeconfig')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('INTG', 'Service account connected to %s (%s)',
|
||||||
|
cluster.name,
|
||||||
|
cluster.server,
|
||||||
|
)
|
||||||
|
|
||||||
|
const kCoreV1Api = kc.makeApiClient(CoreV1Api)
|
||||||
|
|
||||||
|
log.info('INTG', 'Checking pod %s in namespace %s (%s)',
|
||||||
|
pod,
|
||||||
|
namespace,
|
||||||
|
kCoreV1Api.basePath,
|
||||||
|
)
|
||||||
|
|
||||||
|
log.debug('INTG', 'Reading pod info for %s', pod)
|
||||||
|
const { response, body } = await kCoreV1Api.readNamespacedPod(
|
||||||
|
pod,
|
||||||
|
namespace,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
log.error('INTG', 'Failed to read pod info: http %d',
|
||||||
|
response.statusCode,
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Got pod info: %o', body.spec)
|
||||||
|
const shared = body.spec?.shareProcessNamespace
|
||||||
|
if (shared === undefined) {
|
||||||
|
log.error(
|
||||||
|
'INTG',
|
||||||
|
'Pod does not have spec.shareProcessNamespace set',
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shared) {
|
||||||
|
log.error(
|
||||||
|
'INTG',
|
||||||
|
'Pod has set but disabled spec.shareProcessNamespace',
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('INTG', 'Pod %s enabled shared processes', pod)
|
||||||
|
} catch (error) {
|
||||||
|
log.error('INTG', 'Failed to read pod info: %s', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Looking for namespaced process in /proc')
|
||||||
|
const dir = resolve('/proc')
|
||||||
|
try {
|
||||||
|
const subdirs = await readdir(dir)
|
||||||
|
const promises = subdirs.map(async (dir) => {
|
||||||
|
const pid = Number.parseInt(dir, 10)
|
||||||
|
|
||||||
|
if (Number.isNaN(pid)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = join('/proc', dir, 'cmdline')
|
||||||
|
try {
|
||||||
|
log.debug('INTG', 'Reading %s', path)
|
||||||
|
const data = await readFile(path, 'utf8')
|
||||||
|
if (data.includes('headscale')) {
|
||||||
|
return pid
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.debug('INTG', 'Failed to read %s: %s', path, error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(promises)
|
||||||
|
const pids = []
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
|
pids.push(result.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Found Headscale processes: %o', pids)
|
||||||
|
if (pids.length > 1) {
|
||||||
|
log.error('INTG', 'Found %d Headscale processes: %s',
|
||||||
|
pids.length,
|
||||||
|
pids.join(', '),
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pids.length === 0) {
|
||||||
|
log.error('INTG', 'Could not find Headscale process')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
context.pid = pids[0]
|
||||||
|
log.info('INTG', 'Found Headscale process with PID: %d', context.pid)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
log.error('INTG', 'Failed to read /proc')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
onConfigChange: (context) => {
|
||||||
|
if (!context.pid) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('INTG', 'Sending SIGTERM to Headscale')
|
||||||
|
kill(context.pid, 'SIGTERM')
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { readdir, readFile } from 'node:fs/promises'
|
||||||
|
import { platform } from 'node:os'
|
||||||
|
import { join, resolve } from 'node:path'
|
||||||
|
import { kill } from 'node:process'
|
||||||
|
|
||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
|
import { createIntegration } from './integration'
|
||||||
|
|
||||||
|
interface Context {
|
||||||
|
pid: number | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export default createIntegration<Context>({
|
||||||
|
name: 'Native Linux (/proc)',
|
||||||
|
context: {
|
||||||
|
pid: undefined,
|
||||||
|
},
|
||||||
|
isAvailable: async (context) => {
|
||||||
|
if (platform() !== 'linux') {
|
||||||
|
log.error('INTG', '/proc is only available on Linux')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Checking /proc for Headscale process')
|
||||||
|
const dir = resolve('/proc')
|
||||||
|
try {
|
||||||
|
const subdirs = await readdir(dir)
|
||||||
|
const promises = subdirs.map(async (dir) => {
|
||||||
|
const pid = Number.parseInt(dir, 10)
|
||||||
|
|
||||||
|
if (Number.isNaN(pid)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = join('/proc', dir, 'cmdline')
|
||||||
|
try {
|
||||||
|
log.debug('INTG', 'Reading %s', path)
|
||||||
|
const data = await readFile(path, 'utf8')
|
||||||
|
if (data.includes('headscale')) {
|
||||||
|
return pid
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('INTG', 'Failed to read %s: %s', path, error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(promises)
|
||||||
|
const pids = []
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.status === 'fulfilled' && result.value) {
|
||||||
|
pids.push(result.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('INTG', 'Found Headscale processes: %o', pids)
|
||||||
|
if (pids.length > 1) {
|
||||||
|
log.error('INTG', 'Found %d Headscale processes: %s',
|
||||||
|
pids.length,
|
||||||
|
pids.join(', '),
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pids.length === 0) {
|
||||||
|
log.error('INTG', 'Could not find Headscale process')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
context.pid = pids[0]
|
||||||
|
log.info('INTG', 'Found Headscale process with PID: %d', context.pid)
|
||||||
|
return true
|
||||||
|
} catch {
|
||||||
|
log.error('INTG', 'Failed to read /proc')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -4,62 +4,45 @@ import {
|
|||||||
Meta,
|
Meta,
|
||||||
Outlet,
|
Outlet,
|
||||||
Scripts,
|
Scripts,
|
||||||
ScrollRestoration
|
ScrollRestoration,
|
||||||
} from '@remix-run/react'
|
} from '@remix-run/react'
|
||||||
|
|
||||||
import { ErrorPopup } from '~/components/Error'
|
import { ErrorPopup } from '~/components/Error'
|
||||||
import { Toaster } from '~/components/Toaster'
|
import { Toaster } from '~/components/Toaster'
|
||||||
import stylesheet from '~/tailwind.css?url'
|
import stylesheet from '~/tailwind.css?url'
|
||||||
import { getContext, registerConfigWatcher } from '~/utils/config'
|
|
||||||
|
|
||||||
export const meta: MetaFunction = () => [
|
export const meta: MetaFunction = () => [
|
||||||
{ title: 'Headplane' },
|
{ title: 'Headplane' },
|
||||||
{ name: 'description', content: 'A frontend for the headscale coordination server' }
|
{ name: 'description', content: 'A frontend for the headscale coordination server' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export const links: LinksFunction = () => [
|
export const links: LinksFunction = () => [
|
||||||
{ rel: 'stylesheet', href: stylesheet }
|
{ rel: 'stylesheet', href: stylesheet },
|
||||||
]
|
]
|
||||||
|
|
||||||
export async function loader() {
|
|
||||||
const context = await getContext()
|
|
||||||
registerConfigWatcher()
|
|
||||||
|
|
||||||
if (context.headscaleUrl.length === 0) {
|
|
||||||
throw new Error('No headscale URL was provided either by the HEADSCALE_URL environment variable or the config file')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.COOKIE_SECRET) {
|
|
||||||
throw new Error('The COOKIE_SECRET environment variable is required')
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line unicorn/no-null
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang='en'>
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charSet='utf-8'/>
|
<meta charSet="utf-8" />
|
||||||
<meta name='viewport' content='width=device-width, initial-scale=1'/>
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<Meta/>
|
<Meta />
|
||||||
<Links/>
|
<Links />
|
||||||
</head>
|
</head>
|
||||||
<body className='overscroll-none dark:bg-zinc-900 dark:text-white'>
|
<body className="overscroll-none dark:bg-ui-950 dark:text-ui-50">
|
||||||
{children}
|
{children}
|
||||||
<Toaster/>
|
<Toaster />
|
||||||
<ScrollRestoration/>
|
<ScrollRestoration />
|
||||||
<Scripts/>
|
<Scripts />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
export function ErrorBoundary() {
|
||||||
return <ErrorPopup/>
|
return <ErrorPopup />
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return <Outlet/>
|
return <Outlet />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { json } from '@codemirror/lang-json'
|
|
||||||
import { yaml } from '@codemirror/lang-yaml'
|
|
||||||
import { useFetcher } from '@remix-run/react'
|
|
||||||
import { githubDark, githubLight } from '@uiw/codemirror-theme-github'
|
|
||||||
import CodeMirror from '@uiw/react-codemirror'
|
|
||||||
import clsx from 'clsx'
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import CodeMirrorMerge from 'react-codemirror-merge'
|
|
||||||
|
|
||||||
import Button from '~/components/Button'
|
|
||||||
import Spinner from '~/components/Spinner'
|
|
||||||
import { toast } from '~/components/Toaster'
|
|
||||||
|
|
||||||
import Fallback from './fallback'
|
|
||||||
|
|
||||||
type EditorProperties = {
|
|
||||||
readonly acl: string;
|
|
||||||
readonly setAcl: (acl: string) => void;
|
|
||||||
readonly mode: 'edit' | 'diff';
|
|
||||||
|
|
||||||
readonly data: {
|
|
||||||
hasAclWrite: boolean;
|
|
||||||
currentAcl: string;
|
|
||||||
aclType: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Editor({ data, acl, setAcl, mode }: EditorProperties) {
|
|
||||||
const [light, setLight] = useState(false)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
const fetcher = useFetcher()
|
|
||||||
const aclType = useMemo(() => data.aclType === 'json' ? json() : yaml(), [data.aclType])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const theme = window.matchMedia('(prefers-color-scheme: light)')
|
|
||||||
setLight(theme.matches)
|
|
||||||
|
|
||||||
theme.addEventListener('change', theme => {
|
|
||||||
setLight(theme.matches)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Prevents the FOUC
|
|
||||||
setLoading(false)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className={clsx(
|
|
||||||
'border border-gray-200 dark:border-gray-700',
|
|
||||||
'rounded-b-lg rounded-tr-lg mb-2 z-10 overflow-x-hidden'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className='overflow-y-scroll h-editor text-sm'>
|
|
||||||
{loading ? (
|
|
||||||
<Fallback acl={acl} where='client'/>
|
|
||||||
) : (
|
|
||||||
mode === 'edit' ? (
|
|
||||||
<CodeMirror
|
|
||||||
value={acl}
|
|
||||||
theme={light ? githubLight : githubDark}
|
|
||||||
extensions={[aclType]}
|
|
||||||
readOnly={!data.hasAclWrite}
|
|
||||||
onChange={value => {
|
|
||||||
setAcl(value)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CodeMirrorMerge
|
|
||||||
theme={light ? githubLight : githubDark}
|
|
||||||
orientation='a-b'
|
|
||||||
>
|
|
||||||
<CodeMirrorMerge.Original
|
|
||||||
readOnly
|
|
||||||
value={data.currentAcl}
|
|
||||||
extensions={[aclType]}
|
|
||||||
/>
|
|
||||||
<CodeMirrorMerge.Modified
|
|
||||||
readOnly
|
|
||||||
value={acl}
|
|
||||||
extensions={[aclType]}
|
|
||||||
/>
|
|
||||||
</CodeMirrorMerge>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant='emphasized'
|
|
||||||
className='text-sm w-fit mr-2'
|
|
||||||
onClick={() => {
|
|
||||||
fetcher.submit({
|
|
||||||
acl
|
|
||||||
}, {
|
|
||||||
method: 'PATCH',
|
|
||||||
encType: 'application/json'
|
|
||||||
})
|
|
||||||
|
|
||||||
toast('Updated tailnet ACL policy')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{fetcher.state === 'idle' ? undefined : (
|
|
||||||
<Spinner className='w-3 h-3'/>
|
|
||||||
)}
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant='emphasized'
|
|
||||||
className={clsx(
|
|
||||||
'text-sm w-fit bg-gray-100 dark:bg-transparent',
|
|
||||||
'border border-gray-200 dark:border-gray-700'
|
|
||||||
)}
|
|
||||||
onClick={() => {
|
|
||||||
setAcl(data.currentAcl)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Discard Changes
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,51 +1,24 @@
|
|||||||
import clsx from 'clsx'
|
import Spinner from '~/components/Spinner'
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
import Button from '~/components/Button'
|
interface Props {
|
||||||
|
readonly acl: string
|
||||||
type FallbackProperties = {
|
|
||||||
readonly acl: string;
|
|
||||||
readonly where: 'client' | 'server';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Fallback({ acl, where }: FallbackProperties) {
|
export default function Fallback({ acl }: Props) {
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="inline-block relative w-full h-editor">
|
||||||
<div className={clsx(
|
<Spinner className="w-4 h-4 absolute p-2" />
|
||||||
where === 'server' ? 'mb-2 overflow-hidden rounded-tr-lg rounded-b-lg' : '',
|
<textarea
|
||||||
where === 'server' ? 'border border-gray-200 dark:border-gray-700' : ''
|
readOnly
|
||||||
)}
|
className={cn(
|
||||||
>
|
'w-full h-editor font-mono resize-none',
|
||||||
<textarea
|
'text-sm text-gray-600 dark:text-gray-300',
|
||||||
readOnly
|
'bg-ui-100 dark:bg-ui-800',
|
||||||
className={clsx(
|
'pl-10 pt-1 leading-snug',
|
||||||
'w-full h-editor font-mono resize-none',
|
)}
|
||||||
'text-sm text-gray-600 dark:text-gray-300',
|
value={acl}
|
||||||
'pl-10 pt-1 leading-snug'
|
/>
|
||||||
)}
|
</div>
|
||||||
value={acl}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{where === 'server' ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
disabled
|
|
||||||
variant='emphasized'
|
|
||||||
className='text-sm w-fit mr-2'
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
disabled
|
|
||||||
variant='emphasized'
|
|
||||||
className={clsx(
|
|
||||||
'text-sm w-fit bg-gray-100 dark:bg-transparent',
|
|
||||||
'border border-gray-200 dark:border-gray-700'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
Discard Changes
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
) : undefined}
|
|
||||||
</>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,165 +1,356 @@
|
|||||||
import { BeakerIcon, CubeTransparentIcon, EyeIcon, PencilSquareIcon } from '@heroicons/react/24/outline'
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import { type ActionFunctionArgs, json } from '@remix-run/node'
|
import { BeakerIcon, EyeIcon, IssueDraftIcon, PencilIcon } from '@primer/octicons-react'
|
||||||
import { useLoaderData } from '@remix-run/react'
|
import { ActionFunctionArgs, json, LoaderFunctionArgs } from '@remix-run/node'
|
||||||
import { useState } from 'react'
|
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 { 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 Notice from '~/components/Notice'
|
||||||
|
import Spinner from '~/components/Spinner'
|
||||||
|
import { toast } from '~/components/Toaster'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
import { getAcl, getContext, patchAcl } from '~/utils/config'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import { sighupHeadscale } from '~/utils/docker'
|
import { loadConfig } from '~/utils/config/headscale'
|
||||||
|
import { HeadscaleError, pull, put } from '~/utils/headscale'
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
import Editor from './editor'
|
import { Editor, Differ } from './cm.client'
|
||||||
import Fallback from './fallback'
|
import { Unavailable } from './unavailable'
|
||||||
|
import { ErrorView } from './error'
|
||||||
|
|
||||||
export async function loader() {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const context = await getContext()
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
if (!context.hasAcl) {
|
|
||||||
throw new Error('No ACL configuration is available')
|
// 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()
|
||||||
|
let modeGuess = 'database' // Assume database mode
|
||||||
|
if (context.config.read) {
|
||||||
|
const config = await loadConfig()
|
||||||
|
modeGuess = config.policy?.mode ?? 'database'
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data, type } = await getAcl()
|
// Attempt to load the policy, for both the frontend and for checking
|
||||||
return {
|
// if we are able to write to the policy for write access
|
||||||
hasAclWrite: context.hasAclWrite,
|
try {
|
||||||
currentAcl: data,
|
const { policy } = await pull<{ policy: string }>(
|
||||||
aclType: type
|
'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) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
if (!session.has('hsApiKey')) {
|
if (!session.has('hsApiKey')) {
|
||||||
return json({ success: false }, {
|
return json({ success: false, error: null }, {
|
||||||
status: 401
|
status: 401,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await getContext()
|
try {
|
||||||
if (!context.hasAclWrite) {
|
const { acl } = await request.json() as { acl: string }
|
||||||
return json({ success: false }, {
|
const { policy } = await put<{ policy: string }>(
|
||||||
status: 403
|
'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 }
|
return json({ success: true, error: null })
|
||||||
await patchAcl(data.acl)
|
|
||||||
|
|
||||||
if (context.hasDockerSock) {
|
|
||||||
await sighupHeadscale()
|
|
||||||
}
|
|
||||||
|
|
||||||
return json({ success: true })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const data = useLoaderData<typeof loader>()
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{data.hasAclWrite ? undefined : (
|
{data.read && !data.write
|
||||||
<div className='mb-4'>
|
? (
|
||||||
<Notice>
|
<div className="mb-4">
|
||||||
The ACL policy file is readonly to Headplane.
|
<Notice className="w-fit">
|
||||||
You will not be able to make changes here.
|
The ACL policy is read-only. You can view the current policy
|
||||||
</Notice>
|
but you cannot make changes to it.
|
||||||
</div>
|
<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'>
|
<h1 className="text-2xl font-medium mb-4">
|
||||||
Access Control List (ACL)
|
Access Control List (ACL)
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className='mb-4 max-w-prose'>
|
<p className="mb-4 max-w-prose">
|
||||||
The ACL file is used to define the access control rules for your network.
|
The ACL file is used to define the access control rules for your network.
|
||||||
You can find more information about the ACL file in the Tailscale documentation.
|
You can find more information about the ACL file in the
|
||||||
{' '}
|
{' '}
|
||||||
<a
|
<Link
|
||||||
target='_blank'
|
to="https://tailscale.com/kb/1018/acls"
|
||||||
rel='noreferrer'
|
name="Tailscale ACL documentation"
|
||||||
href='https://tailscale.com/kb/1018/acls'
|
|
||||||
className='text-blue-500 dark:text-blue-400 hover:underline'
|
|
||||||
>
|
>
|
||||||
More information
|
Tailscale ACL guide
|
||||||
</a>
|
</Link>
|
||||||
|
{' '}
|
||||||
|
and the
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
to="https://headscale.net/acls"
|
||||||
|
name="Headscale ACL documentation"
|
||||||
|
>
|
||||||
|
Headscale docs
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Tabs>
|
{
|
||||||
<TabList className={cn(
|
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||||
'flex border-t border-gray-200 dark:border-gray-700',
|
fetcher.data?.success === false
|
||||||
'w-fit rounded-t-lg overflow-hidden',
|
? (
|
||||||
'text-gray-400 dark:text-gray-500'
|
// @ts-ignore: useDebounceFetcher is not typed correctly
|
||||||
)}
|
<ErrorView message={fetcher.data.error} />
|
||||||
>
|
) : undefined}
|
||||||
<Tab
|
|
||||||
id='edit'
|
{data.read ? (
|
||||||
className={({ isSelected }) => cn(
|
<>
|
||||||
'px-4 py-2 rounded-tl-lg',
|
<Tabs>
|
||||||
'focus:outline-none flex items-center gap-2',
|
<TabList className={cn(
|
||||||
'border-x border-gray-200 dark:border-gray-700',
|
'flex border-t border-gray-200 dark:border-gray-700',
|
||||||
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
|
'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',
|
||||||
|
})
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<PencilSquareIcon className='w-5 h-5'/>
|
{fetcher.state === 'idle'
|
||||||
<p>Edit file</p>
|
? undefined
|
||||||
</Tab>
|
: (
|
||||||
<Tab
|
<Spinner className="w-3 h-3" />
|
||||||
id='diff'
|
)}
|
||||||
className={({ isSelected }) => cn(
|
Save
|
||||||
'px-4 py-2',
|
</Button>
|
||||||
'focus:outline-none flex items-center gap-2',
|
<Button
|
||||||
'border-x border-gray-200 dark:border-gray-700',
|
isDisabled={disabled}
|
||||||
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
|
onPress={() => {
|
||||||
)}
|
setAcl(data?.policy ?? '')
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<EyeIcon className='w-5 h-5'/>
|
Discard Changes
|
||||||
<p>Preview changes</p>
|
</Button>
|
||||||
</Tab>
|
</>
|
||||||
<Tab
|
) : <Unavailable mode={data.mode as "database" | "file"} />}
|
||||||
id='preview'
|
|
||||||
className={({ isSelected }) => cn(
|
|
||||||
'px-4 py-2 rounded-tr-lg',
|
|
||||||
'focus:outline-none flex items-center gap-2',
|
|
||||||
'border-x border-gray-200 dark:border-gray-700',
|
|
||||||
isSelected ? 'text-gray-900 dark:text-gray-100' : ''
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<BeakerIcon className='w-5 h-5'/>
|
|
||||||
<p>Preview rules</p>
|
|
||||||
</Tab>
|
|
||||||
</TabList>
|
|
||||||
<TabPanel id='edit'>
|
|
||||||
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
|
|
||||||
{() => (
|
|
||||||
<Editor data={data} acl={acl} setAcl={setAcl} mode='edit'/>
|
|
||||||
)}
|
|
||||||
</ClientOnly>
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel id='diff'>
|
|
||||||
<ClientOnly fallback={<Fallback acl={acl} where='server'/>}>
|
|
||||||
{() => (
|
|
||||||
<Editor data={data} acl={acl} setAcl={setAcl} mode='diff'/>
|
|
||||||
)}
|
|
||||||
</ClientOnly>
|
|
||||||
</TabPanel>
|
|
||||||
<TabPanel id='preview'>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'border border-gray-200 dark:border-gray-700',
|
|
||||||
'rounded-b-lg rounded-tr-lg mb-4 overflow-hidden',
|
|
||||||
'p-16 flex flex-col items-center justify-center'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CubeTransparentIcon className='w-24 h-24 text-gray-300 dark:text-gray-500'/>
|
|
||||||
<p className='w-1/2 text-center mt-4'>
|
|
||||||
The Preview rules is very much still a work in progress.
|
|
||||||
It is a bit complicated to implement right now but hopefully it will be available soon.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
|
||||||
</Tabs>
|
|
||||||
</div>
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { Form, useSubmit } from '@remix-run/react'
|
||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import Code from '~/components/Code'
|
||||||
|
import Dialog from '~/components/Dialog'
|
||||||
|
import TextField from '~/components/TextField'
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
records: { name: string, type: 'A', value: string }[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddDNS({ records }: Props) {
|
||||||
|
const submit = useSubmit()
|
||||||
|
const [name, setName] = useState('')
|
||||||
|
const [ip, setIp] = useState('')
|
||||||
|
|
||||||
|
const isDuplicate = useMemo(() => {
|
||||||
|
if (name.length === 0 || ip.length === 0) return false
|
||||||
|
const lookup = records.find(record => record.name === name)
|
||||||
|
if (!lookup) return false
|
||||||
|
|
||||||
|
return lookup.value === ip
|
||||||
|
}, [records, name, ip])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Dialog.Button>
|
||||||
|
Add DNS record
|
||||||
|
</Dialog.Button>
|
||||||
|
<Dialog.Panel>
|
||||||
|
{close => (
|
||||||
|
<>
|
||||||
|
<Dialog.Title>
|
||||||
|
Add DNS record
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Text>
|
||||||
|
Enter the domain and IP address for the new DNS record.
|
||||||
|
</Dialog.Text>
|
||||||
|
<Form
|
||||||
|
method="POST"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!name || !ip) return
|
||||||
|
|
||||||
|
setName('')
|
||||||
|
setIp('')
|
||||||
|
|
||||||
|
submit({
|
||||||
|
'dns.extra_records': [
|
||||||
|
...records,
|
||||||
|
{
|
||||||
|
name,
|
||||||
|
type: 'A',
|
||||||
|
value: ip,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
})
|
||||||
|
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="Domain"
|
||||||
|
placeholder="test.example.com"
|
||||||
|
name="domain"
|
||||||
|
state={[name, setName]}
|
||||||
|
className={cn(
|
||||||
|
'mt-2',
|
||||||
|
isDuplicate && 'outline outline-red-500',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="IP Address"
|
||||||
|
placeholder="101.101.101.101"
|
||||||
|
name="ip"
|
||||||
|
state={[ip, setIp]}
|
||||||
|
className={cn(
|
||||||
|
isDuplicate && 'outline outline-red-500',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isDuplicate
|
||||||
|
? (
|
||||||
|
<p className="text-sm opacity-50">
|
||||||
|
A record with the domain name
|
||||||
|
{' '}
|
||||||
|
<Code>{name}</Code>
|
||||||
|
{' '}
|
||||||
|
and IP address
|
||||||
|
{' '}
|
||||||
|
<Code>{ip}</Code>
|
||||||
|
{' '}
|
||||||
|
already exists.
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
<div className="mt-6 flex justify-end gap-2 mt-8">
|
||||||
|
<Dialog.Action
|
||||||
|
variant="cancel"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Action>
|
||||||
|
<Dialog.Action
|
||||||
|
variant="confirm"
|
||||||
|
onPress={close}
|
||||||
|
isDisabled={isDuplicate}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { RepoForkedIcon } from '@primer/octicons-react'
|
||||||
|
import { Form, useSubmit } from '@remix-run/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import Dialog from '~/components/Dialog'
|
||||||
|
import Switch from '~/components/Switch'
|
||||||
|
import TextField from '~/components/TextField'
|
||||||
|
import Tooltip from '~/components/Tooltip'
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nameservers: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddNameserver({ nameservers }: Props) {
|
||||||
|
const submit = useSubmit()
|
||||||
|
const [split, setSplit] = useState(false)
|
||||||
|
const [ns, setNs] = useState('')
|
||||||
|
const [domain, setDomain] = useState('')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Dialog.Button>
|
||||||
|
Add nameserver
|
||||||
|
</Dialog.Button>
|
||||||
|
<Dialog.Panel>
|
||||||
|
{close => (
|
||||||
|
<>
|
||||||
|
<Dialog.Title>
|
||||||
|
Add nameserver
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Text className="font-semibold">
|
||||||
|
Nameserver
|
||||||
|
</Dialog.Text>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
Use this IPv4 or IPv6 address to resolve names.
|
||||||
|
</Dialog.Text>
|
||||||
|
<Form
|
||||||
|
method="POST"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (!ns) return
|
||||||
|
|
||||||
|
if (split) {
|
||||||
|
const splitNs: Record<string, string[]> = {}
|
||||||
|
for (const [key, value] of Object.entries(nameservers)) {
|
||||||
|
if (key === 'global') continue
|
||||||
|
splitNs[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(splitNs).includes(domain)) {
|
||||||
|
splitNs[domain].push(ns)
|
||||||
|
} else {
|
||||||
|
splitNs[domain] = [ns]
|
||||||
|
}
|
||||||
|
|
||||||
|
submit({
|
||||||
|
'dns.nameservers.split': splitNs,
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const globalNs = nameservers.global
|
||||||
|
globalNs.push(ns)
|
||||||
|
|
||||||
|
submit({
|
||||||
|
'dns.nameservers.global': globalNs,
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setNs('')
|
||||||
|
setDomain('')
|
||||||
|
setSplit(false)
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextField
|
||||||
|
label="DNS Server"
|
||||||
|
placeholder="1.2.3.4"
|
||||||
|
name="ns"
|
||||||
|
state={[ns, setNs]}
|
||||||
|
className="mt-2 mb-8"
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="block">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<Dialog.Text className="font-semibold">
|
||||||
|
Restrict to domain
|
||||||
|
</Dialog.Text>
|
||||||
|
<Tooltip>
|
||||||
|
<Tooltip.Button className={cn(
|
||||||
|
'text-xs rounded-md px-1.5 py-0.5',
|
||||||
|
'bg-ui-200 dark:bg-ui-800',
|
||||||
|
'text-ui-600 dark:text-ui-300',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<RepoForkedIcon className="w-4 h-4 mr-0.5" />
|
||||||
|
Split DNS
|
||||||
|
</Tooltip.Button>
|
||||||
|
<Tooltip.Body>
|
||||||
|
Only clients that support split DNS
|
||||||
|
(Tailscale v1.8 or later for most platforms)
|
||||||
|
will use this nameserver. Older clients
|
||||||
|
will ignore it.
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
This nameserver will only be used for some domains.
|
||||||
|
</Dialog.Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
label="Split DNS"
|
||||||
|
defaultSelected={split}
|
||||||
|
onChange={() => { setSplit(!split) }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{split
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
<Dialog.Text className="font-semibold mt-8">
|
||||||
|
Domain
|
||||||
|
</Dialog.Text>
|
||||||
|
<TextField
|
||||||
|
label="Domain"
|
||||||
|
placeholder="example.com"
|
||||||
|
name="domain"
|
||||||
|
state={[domain, setDomain]}
|
||||||
|
className="my-2"
|
||||||
|
/>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
Only single-label or fully-qualified queries
|
||||||
|
matching this suffix should use the nameserver.
|
||||||
|
</Dialog.Text>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||||
|
<Dialog.Action
|
||||||
|
variant="cancel"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Action>
|
||||||
|
<Dialog.Action
|
||||||
|
variant="confirm"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { useSubmit } from '@remix-run/react'
|
||||||
|
import { Button } from 'react-aria-components'
|
||||||
|
|
||||||
|
import Code from '~/components/Code'
|
||||||
|
import Link from '~/components/Link'
|
||||||
|
import TableList from '~/components/TableList'
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
|
import AddDNS from './dialogs/dns'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
records: { name: string, type: 'A', value: string }[]
|
||||||
|
isDisabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DNS({ records, isDisabled }: Props) {
|
||||||
|
const submit = useSubmit()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-2/3">
|
||||||
|
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300">
|
||||||
|
Headscale supports adding custom DNS records to your Tailnet.
|
||||||
|
As of now, only
|
||||||
|
{' '}
|
||||||
|
<Code>A</Code>
|
||||||
|
{' '}
|
||||||
|
records are supported.
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
to="https://headscale.net/dns-records/"
|
||||||
|
name="Headscale DNS Records documentation"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
<TableList className="mb-8">
|
||||||
|
{records.length === 0
|
||||||
|
? (
|
||||||
|
<TableList.Item>
|
||||||
|
<p className="opacity-50 text-sm mx-auto">
|
||||||
|
No DNS records found
|
||||||
|
</p>
|
||||||
|
</TableList.Item>
|
||||||
|
)
|
||||||
|
: records.map((record, index) => (
|
||||||
|
<TableList.Item key={index}>
|
||||||
|
<div className="flex gap-24">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<p className="font-mono text-sm font-bold">{record.type}</p>
|
||||||
|
<p className="font-mono text-sm">{record.name}</p>
|
||||||
|
</div>
|
||||||
|
<p className="font-mono text-sm">{record.value}</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'text-sm',
|
||||||
|
'text-red-600 dark:text-red-400',
|
||||||
|
'hover:text-red-700 dark:hover:text-red-300',
|
||||||
|
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onPress={() => {
|
||||||
|
submit({
|
||||||
|
'dns.extra_records': records
|
||||||
|
.filter((_, i) => i !== index),
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</TableList.Item>
|
||||||
|
))}
|
||||||
|
</TableList>
|
||||||
|
|
||||||
|
{isDisabled
|
||||||
|
? undefined
|
||||||
|
: (
|
||||||
|
<AddDNS records={records} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -15,15 +15,14 @@ import {
|
|||||||
verticalListSortingStrategy
|
verticalListSortingStrategy
|
||||||
} from '@dnd-kit/sortable'
|
} from '@dnd-kit/sortable'
|
||||||
import { CSS } from '@dnd-kit/utilities'
|
import { CSS } from '@dnd-kit/utilities'
|
||||||
import { Bars3Icon, LockClosedIcon } from '@heroicons/react/24/outline'
|
import { LockIcon, ThreeBarsIcon } from '@primer/octicons-react'
|
||||||
import { type FetcherWithComponents, useFetcher } from '@remix-run/react'
|
import { type FetcherWithComponents, useFetcher } from '@remix-run/react'
|
||||||
import clsx from 'clsx'
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Button, Input } from 'react-aria-components'
|
||||||
|
|
||||||
import Button from '~/components/Button'
|
|
||||||
import Input from '~/components/Input'
|
|
||||||
import Spinner from '~/components/Spinner'
|
import Spinner from '~/components/Spinner'
|
||||||
import TableList from '~/components/TableList'
|
import TableList from '~/components/TableList'
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
type Properties = {
|
type Properties = {
|
||||||
readonly baseDomain?: string;
|
readonly baseDomain?: string;
|
||||||
@@ -83,7 +82,7 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
|
|||||||
{baseDomain ? (
|
{baseDomain ? (
|
||||||
<TableList.Item key='magic-dns-sd'>
|
<TableList.Item key='magic-dns-sd'>
|
||||||
<p className='font-mono text-sm'>{baseDomain}</p>
|
<p className='font-mono text-sm'>{baseDomain}</p>
|
||||||
<LockClosedIcon className='h-4 w-4'/>
|
<LockIcon className='h-4 w-4'/>
|
||||||
</TableList.Item>
|
</TableList.Item>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<SortableContext
|
<SortableContext
|
||||||
@@ -115,9 +114,8 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
|
|||||||
{disabled ? undefined : (
|
{disabled ? undefined : (
|
||||||
<TableList.Item key='add-sd'>
|
<TableList.Item key='add-sd'>
|
||||||
<Input
|
<Input
|
||||||
variant='embedded'
|
|
||||||
type='text'
|
type='text'
|
||||||
className='font-mono text-sm'
|
className='font-mono text-sm bg-transparent w-full mr-2'
|
||||||
placeholder='Search Domain'
|
placeholder='Search Domain'
|
||||||
value={newDomain}
|
value={newDomain}
|
||||||
onChange={event => {
|
onChange={event => {
|
||||||
@@ -126,12 +124,17 @@ export default function Domains({ baseDomain, searchDomains, disabled }: Propert
|
|||||||
/>
|
/>
|
||||||
{fetcher.state === 'idle' ? (
|
{fetcher.state === 'idle' ? (
|
||||||
<Button
|
<Button
|
||||||
className='text-sm'
|
className={cn(
|
||||||
disabled={newDomain.length === 0}
|
'text-sm font-semibold',
|
||||||
onClick={() => {
|
'text-blue-600 dark:text-blue-400',
|
||||||
|
'hover:text-blue-700 dark:hover:text-blue-300',
|
||||||
|
newDomain.length === 0 && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
isDisabled={newDomain.length === 0}
|
||||||
|
onPress={() => {
|
||||||
fetcher.submit({
|
fetcher.submit({
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
'dns_config.domains': [...localDomains, newDomain]
|
'dns.search_domains': [...localDomains, newDomain]
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json'
|
encType: 'application/json'
|
||||||
@@ -177,7 +180,7 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={clsx(
|
className={cn(
|
||||||
'flex items-center justify-between px-3 py-2',
|
'flex items-center justify-between px-3 py-2',
|
||||||
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
|
'border-b border-gray-200 last:border-b-0 dark:border-zinc-800',
|
||||||
isDragging ? 'text-gray-400' : '',
|
isDragging ? 'text-gray-400' : '',
|
||||||
@@ -190,7 +193,7 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
|
|||||||
>
|
>
|
||||||
<p className='font-mono text-sm flex items-center gap-4'>
|
<p className='font-mono text-sm flex items-center gap-4'>
|
||||||
{disabled ? undefined : (
|
{disabled ? undefined : (
|
||||||
<Bars3Icon
|
<ThreeBarsIcon
|
||||||
className='h-4 w-4 text-gray-400 focus:outline-none'
|
className='h-4 w-4 text-gray-400 focus:outline-none'
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
@@ -200,13 +203,16 @@ function Domain({ domain, id, localDomains, isDrag, disabled, fetcher }: DomainP
|
|||||||
</p>
|
</p>
|
||||||
{isDrag ? undefined : (
|
{isDrag ? undefined : (
|
||||||
<Button
|
<Button
|
||||||
variant='destructive'
|
className={cn(
|
||||||
className='text-sm'
|
'text-sm',
|
||||||
disabled={disabled}
|
'text-red-600 dark:text-red-400',
|
||||||
onClick={() => {
|
'hover:text-red-700 dark:hover:text-red-300',
|
||||||
|
disabled && 'opacity-50 cursor-not-allowed'
|
||||||
|
)}
|
||||||
|
isDisabled={disabled}
|
||||||
|
onPress={() => {
|
||||||
fetcher.submit({
|
fetcher.submit({
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
'dns.search_domains': localDomains.filter((_, index) => index !== id - 1)
|
||||||
'dns_config.domains': localDomains.filter((_, index) => index !== id - 1)
|
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json'
|
encType: 'application/json'
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useFetcher } from '@remix-run/react'
|
|||||||
|
|
||||||
import Dialog from '~/components/Dialog'
|
import Dialog from '~/components/Dialog'
|
||||||
import Spinner from '~/components/Spinner'
|
import Spinner from '~/components/Spinner'
|
||||||
import { cn } from '~/utils/cn'
|
|
||||||
|
|
||||||
type Properties = {
|
type Properties = {
|
||||||
readonly isEnabled: boolean;
|
readonly isEnabled: boolean;
|
||||||
@@ -15,14 +14,7 @@ export default function Modal({ isEnabled, disabled }: Properties) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Button
|
<Dialog.Button isDisabled={disabled}>
|
||||||
isDisabled={disabled}
|
|
||||||
className={cn(
|
|
||||||
'w-fit text-sm rounded-lg px-4 py-2',
|
|
||||||
'bg-main-700 dark:bg-main-800 text-white',
|
|
||||||
disabled && 'opacity-50 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{fetcher.state === 'idle' ? undefined : (
|
{fetcher.state === 'idle' ? undefined : (
|
||||||
<Spinner className='w-3 h-3'/>
|
<Spinner className='w-3 h-3'/>
|
||||||
)}
|
)}
|
||||||
@@ -50,7 +42,7 @@ export default function Modal({ isEnabled, disabled }: Properties) {
|
|||||||
onPress={() => {
|
onPress={() => {
|
||||||
fetcher.submit({
|
fetcher.submit({
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
'dns_config.magic_dns': !isEnabled
|
'dns.magic_dns': !isEnabled
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json'
|
encType: 'application/json'
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { useSubmit } from '@remix-run/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Button } from 'react-aria-components'
|
||||||
|
|
||||||
|
import Link from '~/components/Link'
|
||||||
|
import Switch from '~/components/Switch'
|
||||||
|
import TableList from '~/components/TableList'
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
|
import AddNameserver from './dialogs/nameserver'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
nameservers: Record<string, string[]>
|
||||||
|
isDisabled: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Nameservers({ nameservers, isDisabled }: Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col w-2/3">
|
||||||
|
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||||
|
<p className="text-gray-700 dark:text-gray-300">
|
||||||
|
Set the nameservers used by devices on the Tailnet
|
||||||
|
to resolve DNS queries.
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
to="https://tailscale.com/kb/1054/dns"
|
||||||
|
name="Tailscale DNS Documentation"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
<div className="mt-4">
|
||||||
|
{Object.keys(nameservers).map(key => (
|
||||||
|
<NameserverList
|
||||||
|
key={key}
|
||||||
|
isGlobal={key === 'global'}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
nameservers={nameservers}
|
||||||
|
name={key}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isDisabled
|
||||||
|
? undefined
|
||||||
|
: (
|
||||||
|
<AddNameserver nameservers={nameservers} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ListProps {
|
||||||
|
isGlobal: boolean
|
||||||
|
isDisabled: boolean
|
||||||
|
nameservers: string[]
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function NameserverList({ isGlobal, isDisabled, nameservers, name }: ListProps) {
|
||||||
|
const submit = useSubmit()
|
||||||
|
const list = isGlobal ? nameservers['global'] : nameservers[name]
|
||||||
|
if (list.length === 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h2 className="text-md font-medium opacity-80">
|
||||||
|
{isGlobal ? 'Global Nameservers' : name}
|
||||||
|
</h2>
|
||||||
|
</div>
|
||||||
|
<TableList>
|
||||||
|
{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>
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'text-sm',
|
||||||
|
'text-red-600 dark:text-red-400',
|
||||||
|
'hover:text-red-700 dark:hover:text-red-300',
|
||||||
|
isDisabled && 'opacity-50 cursor-not-allowed',
|
||||||
|
)}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
onPress={() => {
|
||||||
|
if (isGlobal) {
|
||||||
|
submit({
|
||||||
|
'dns.nameservers.global': list
|
||||||
|
.filter((_, i) => i !== index),
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
submit({
|
||||||
|
'dns.nameservers.split': {
|
||||||
|
...nameservers,
|
||||||
|
[name]: list.filter((_, i) => i !== index),
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
method: 'PATCH',
|
||||||
|
encType: 'application/json',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</TableList.Item>
|
||||||
|
)) : undefined}
|
||||||
|
</TableList>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
/* eslint-disable unicorn/no-keyword-prefix */
|
/* eslint-disable unicorn/no-keyword-prefix */
|
||||||
import { useFetcher } from '@remix-run/react'
|
import { useFetcher } from '@remix-run/react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
import { Input } from 'react-aria-components'
|
||||||
|
|
||||||
import Code from '~/components/Code'
|
import Code from '~/components/Code'
|
||||||
import Dialog from '~/components/Dialog'
|
import Dialog from '~/components/Dialog'
|
||||||
import Input from '~/components/Input'
|
|
||||||
import Spinner from '~/components/Spinner'
|
import Spinner from '~/components/Spinner'
|
||||||
import TextField from '~/components/TextField'
|
import TextField from '~/components/TextField'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
@@ -28,14 +28,19 @@ export default function Modal({ name, disabled }: Properties) {
|
|||||||
Devices are accessible at
|
Devices are accessible at
|
||||||
{' '}
|
{' '}
|
||||||
<Code>
|
<Code>
|
||||||
[device].[user].{name}
|
[device].{name}
|
||||||
</Code>
|
</Code>
|
||||||
{' '}
|
{' '}
|
||||||
when Magic DNS is enabled.
|
when Magic DNS is enabled.
|
||||||
</p>
|
</p>
|
||||||
<Input
|
<Input
|
||||||
readOnly
|
readOnly
|
||||||
className='font-mono text-sm my-4 w-1/2'
|
className={cn(
|
||||||
|
'block px-2.5 py-1.5 w-1/2 rounded-lg my-4',
|
||||||
|
'border border-ui-200 dark:border-ui-600',
|
||||||
|
'dark:bg-ui-800 dark:text-ui-300 text-sm',
|
||||||
|
'outline-none'
|
||||||
|
)}
|
||||||
type='text'
|
type='text'
|
||||||
value={name}
|
value={name}
|
||||||
onFocus={event => {
|
onFocus={event => {
|
||||||
@@ -43,14 +48,7 @@ export default function Modal({ name, disabled }: Properties) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Button
|
<Dialog.Button isDisabled={disabled}>
|
||||||
isDisabled={disabled}
|
|
||||||
className={cn(
|
|
||||||
'w-fit text-sm rounded-lg px-4 py-2',
|
|
||||||
'bg-main-700 dark:bg-main-800 text-white',
|
|
||||||
disabled && 'opacity-50 cursor-not-allowed'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{fetcher.state === 'idle' ? undefined : (
|
{fetcher.state === 'idle' ? undefined : (
|
||||||
<Spinner className='w-3 h-3'/>
|
<Spinner className='w-3 h-3'/>
|
||||||
)}
|
)}
|
||||||
@@ -82,7 +80,7 @@ export default function Modal({ name, disabled }: Properties) {
|
|||||||
variant='confirm'
|
variant='confirm'
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
fetcher.submit({
|
fetcher.submit({
|
||||||
'dns_config.base_domain': newName
|
'dns.base_domain': newName
|
||||||
}, {
|
}, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
encType: 'application/json'
|
encType: 'application/json'
|
||||||
|
|||||||
@@ -1,46 +1,42 @@
|
|||||||
import { type ActionFunctionArgs } from '@remix-run/node'
|
import { type ActionFunctionArgs } from '@remix-run/node'
|
||||||
import { json, useFetcher, useLoaderData } from '@remix-run/react'
|
import { json, useLoaderData } from '@remix-run/react'
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
import Button from '~/components/Button'
|
|
||||||
import Code from '~/components/Code'
|
import Code from '~/components/Code'
|
||||||
import Input from '~/components/Input'
|
|
||||||
import Notice from '~/components/Notice'
|
import Notice from '~/components/Notice'
|
||||||
import Spinner from '~/components/Spinner'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import Switch from '~/components/Switch'
|
import { loadConfig, patchConfig } from '~/utils/config/headscale'
|
||||||
import TableList from '~/components/TableList'
|
|
||||||
import { getConfig, getContext, patchConfig } from '~/utils/config'
|
|
||||||
import { restartHeadscale } from '~/utils/docker'
|
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
import { useLiveData } from '~/utils/useLiveData'
|
import { useLiveData } from '~/utils/useLiveData'
|
||||||
|
|
||||||
|
import DNS from './dns'
|
||||||
import Domains from './domains'
|
import Domains from './domains'
|
||||||
import MagicModal from './magic'
|
import MagicModal from './magic'
|
||||||
|
import Nameservers from './nameservers'
|
||||||
import RenameModal from './rename'
|
import RenameModal from './rename'
|
||||||
|
|
||||||
// We do not want to expose every config value
|
// We do not want to expose every config value
|
||||||
export async function loader() {
|
export async function loader() {
|
||||||
const context = await getContext()
|
const context = await loadContext()
|
||||||
if (!context.hasConfig) {
|
if (!context.config.read) {
|
||||||
throw new Error('No configuration is available')
|
throw new Error('No configuration is available')
|
||||||
}
|
}
|
||||||
|
|
||||||
const config = await getConfig()
|
const config = await loadConfig()
|
||||||
|
|
||||||
const dns = {
|
const dns = {
|
||||||
prefixes: config.prefixes,
|
prefixes: config.prefixes,
|
||||||
magicDns: config.dns_config.magic_dns,
|
magicDns: config.dns.magic_dns,
|
||||||
baseDomain: config.dns_config.base_domain,
|
baseDomain: config.dns.use_username_in_magic_dns
|
||||||
overrideLocal: config.dns_config.override_local_dns,
|
? `[user].${config.dns.base_domain}`
|
||||||
nameservers: config.dns_config.nameservers,
|
: config.dns.base_domain,
|
||||||
splitDns: config.dns_config.restricted_nameservers,
|
nameservers: config.dns.nameservers.global,
|
||||||
searchDomains: config.dns_config.domains,
|
splitDns: config.dns.nameservers.split,
|
||||||
extraRecords: config.dns_config.extra_records
|
searchDomains: config.dns.search_domains,
|
||||||
|
extraRecords: config.dns.extra_records,
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...dns,
|
...dns,
|
||||||
...context
|
...context,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,153 +44,78 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
const session = await getSession(request.headers.get('Cookie'))
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
if (!session.has('hsApiKey')) {
|
if (!session.has('hsApiKey')) {
|
||||||
return json({ success: false }, {
|
return json({ success: false }, {
|
||||||
status: 401
|
status: 401,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await getContext()
|
const context = await loadContext()
|
||||||
if (!context.hasConfigWrite) {
|
if (!context.config.write) {
|
||||||
return json({ success: false }, {
|
return json({ success: false }, {
|
||||||
status: 403
|
status: 403,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await request.json() as Record<string, unknown>
|
const data = await request.json() as Record<string, unknown>
|
||||||
await patchConfig(data)
|
await patchConfig(data)
|
||||||
await restartHeadscale()
|
|
||||||
|
if (context.integration?.onConfigChange) {
|
||||||
|
await context.integration.onConfigChange(context.integration.context)
|
||||||
|
}
|
||||||
|
|
||||||
return json({ success: true })
|
return json({ success: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
useLiveData({ interval: 5000 })
|
useLiveData({ interval: 5000 })
|
||||||
const data = useLoaderData<typeof loader>()
|
const data = useLoaderData<typeof loader>()
|
||||||
const fetcher = useFetcher()
|
|
||||||
const [localOverride, setLocalOverride] = useState(data.overrideLocal)
|
const allNs: Record<string, string[]> = {}
|
||||||
const [ns, setNs] = useState('')
|
for (const key of Object.keys(data.splitDns)) {
|
||||||
|
allNs[key] = data.splitDns[key]
|
||||||
|
}
|
||||||
|
|
||||||
|
allNs.global = data.nameservers
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex flex-col gap-16 max-w-screen-lg'>
|
<div className="flex flex-col gap-16 max-w-screen-lg">
|
||||||
{data.hasConfigWrite ? undefined : (
|
{data.config.write
|
||||||
<Notice>
|
? undefined
|
||||||
The Headscale configuration is read-only. You cannot make changes to the configuration
|
: (
|
||||||
</Notice>
|
<Notice>
|
||||||
)}
|
The Headscale configuration is read-only. You cannot make changes to the configuration
|
||||||
<RenameModal name={data.baseDomain} disabled={!data.hasConfigWrite}/>
|
</Notice>
|
||||||
<div className='flex flex-col w-2/3'>
|
)}
|
||||||
<h1 className='text-2xl font-medium mb-4'>Nameservers</h1>
|
<RenameModal name={data.baseDomain} disabled={!data.config.write} />
|
||||||
<p className='text-gray-700 dark:text-gray-300'>
|
<Nameservers
|
||||||
Set the nameservers used by devices on the Tailnet
|
nameservers={allNs}
|
||||||
to resolve DNS queries.
|
isDisabled={!data.config.write}
|
||||||
</p>
|
/>
|
||||||
<div className='mt-4'>
|
|
||||||
<div className='flex items-center justify-between mb-2'>
|
|
||||||
<h2 className='text-md font-medium opacity-80'>
|
|
||||||
Global Nameservers
|
|
||||||
</h2>
|
|
||||||
<div className='flex gap-2 items-center'>
|
|
||||||
<span className='text-sm opacity-50'>
|
|
||||||
Override local DNS
|
|
||||||
</span>
|
|
||||||
<Switch
|
|
||||||
label='Override local DNS'
|
|
||||||
defaultSelected={localOverride}
|
|
||||||
isDisabled={!data.hasConfigWrite}
|
|
||||||
onChange={() => {
|
|
||||||
fetcher.submit({
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
'dns_config.override_local_dns': !localOverride
|
|
||||||
}, {
|
|
||||||
method: 'PATCH',
|
|
||||||
encType: 'application/json'
|
|
||||||
})
|
|
||||||
|
|
||||||
setLocalOverride(!localOverride)
|
<DNS
|
||||||
}}
|
records={data.extraRecords}
|
||||||
/>
|
isDisabled={!data.config.write}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
<TableList>
|
|
||||||
{data.nameservers.map((ns, index) => (
|
|
||||||
// eslint-disable-next-line react/no-array-index-key
|
|
||||||
<TableList.Item key={index}>
|
|
||||||
<p className='font-mono text-sm'>{ns}</p>
|
|
||||||
<Button
|
|
||||||
variant='destructive'
|
|
||||||
className='text-sm'
|
|
||||||
disabled={!data.hasConfigWrite}
|
|
||||||
onClick={() => {
|
|
||||||
fetcher.submit({
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
'dns_config.nameservers': data.nameservers.filter((_, index_) => index_ !== index)
|
|
||||||
}, {
|
|
||||||
method: 'PATCH',
|
|
||||||
encType: 'application/json'
|
|
||||||
})
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Button>
|
|
||||||
</TableList.Item>
|
|
||||||
))}
|
|
||||||
{data.hasConfigWrite ? (
|
|
||||||
<TableList.Item>
|
|
||||||
<Input
|
|
||||||
variant='embedded'
|
|
||||||
type='text'
|
|
||||||
className='font-mono text-sm'
|
|
||||||
placeholder='Nameserver'
|
|
||||||
value={ns}
|
|
||||||
onChange={event => {
|
|
||||||
setNs(event.target.value)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{fetcher.state === 'idle' ? (
|
|
||||||
<Button
|
|
||||||
className='text-sm'
|
|
||||||
disabled={ns.length === 0}
|
|
||||||
onClick={() => {
|
|
||||||
fetcher.submit({
|
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
|
||||||
'dns_config.nameservers': [...data.nameservers, ns]
|
|
||||||
}, {
|
|
||||||
method: 'PATCH',
|
|
||||||
encType: 'application/json'
|
|
||||||
})
|
|
||||||
|
|
||||||
setNs('')
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Add
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Spinner className='w-3 h-3 mr-0'/>
|
|
||||||
)}
|
|
||||||
</TableList.Item>
|
|
||||||
) : undefined}
|
|
||||||
</TableList>
|
|
||||||
{/* TODO: Split DNS and Custom A Records */}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Domains
|
<Domains
|
||||||
baseDomain={data.magicDns ? data.baseDomain : undefined}
|
baseDomain={data.magicDns ? data.baseDomain : undefined}
|
||||||
searchDomains={data.searchDomains}
|
searchDomains={data.searchDomains}
|
||||||
disabled={!data.hasConfigWrite}
|
disabled={!data.config.write}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className='flex flex-col w-2/3'>
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className='text-2xl font-medium mb-4'>Magic DNS</h1>
|
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
|
||||||
<p className='text-gray-700 dark:text-gray-300 mb-4'>
|
<p className="text-gray-700 dark:text-gray-300 mb-4">
|
||||||
Automaticall register domain names for each device
|
Automatically register domain names for each device
|
||||||
on the tailnet. Devices will be accessible at
|
on the tailnet. Devices will be accessible at
|
||||||
{' '}
|
{' '}
|
||||||
<Code>
|
<Code>
|
||||||
[device].[user].{data.baseDomain}
|
[device].
|
||||||
|
{data.baseDomain}
|
||||||
</Code>
|
</Code>
|
||||||
{' '}
|
{' '}
|
||||||
when Magic DNS is enabled.
|
when Magic DNS is enabled.
|
||||||
</p>
|
</p>
|
||||||
<MagicModal isEnabled={data.magicDns} disabled={!data.hasConfigWrite}/>
|
<MagicModal isEnabled={data.magicDns} disabled={!data.config.write} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
import { type LoaderFunctionArgs } from '@remix-run/node'
|
|
||||||
import { Link, useLoaderData } from '@remix-run/react'
|
|
||||||
|
|
||||||
import Attribute from '~/components/Attribute'
|
|
||||||
import Card from '~/components/Card'
|
|
||||||
import StatusCircle from '~/components/StatusCircle'
|
|
||||||
import { type Machine } from '~/types'
|
|
||||||
import { pull } from '~/utils/headscale'
|
|
||||||
import { getSession } from '~/utils/sessions'
|
|
||||||
import { useLiveData } from '~/utils/useLiveData'
|
|
||||||
|
|
||||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
|
||||||
if (!params.id) {
|
|
||||||
throw new Error('No machine ID provided')
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const data = await pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!)
|
|
||||||
return data.node
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const data = useLoaderData<typeof loader>()
|
|
||||||
useLiveData({ interval: 1000 })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<p className='mb-4 text-gray-500 dark:text-gray-400 text-sm'>
|
|
||||||
<Link
|
|
||||||
to='/machines'
|
|
||||||
className='font-bold text-gray-700 dark:text-gray-300 hover:underline'
|
|
||||||
>
|
|
||||||
All Machines
|
|
||||||
</Link>
|
|
||||||
{' / '}
|
|
||||||
{data.givenName}
|
|
||||||
</p>
|
|
||||||
<span className='flex items-baseline gap-x-4 text-sm mb-4'>
|
|
||||||
<h1 className='text-2xl font-bold'>
|
|
||||||
{data.givenName}
|
|
||||||
</h1>
|
|
||||||
<StatusCircle isOnline={data.online} className='w-4 h-4'/>
|
|
||||||
</span>
|
|
||||||
<Card>
|
|
||||||
<Attribute name='Creator' value={data.user.name}/>
|
|
||||||
<Attribute name='Node ID' value={data.id}/>
|
|
||||||
<Attribute name='Node Name' value={data.givenName}/>
|
|
||||||
<Attribute name='Hostname' value={data.name}/>
|
|
||||||
<Attribute
|
|
||||||
isCopyable
|
|
||||||
name='Node Key'
|
|
||||||
value={data.nodeKey}
|
|
||||||
/>
|
|
||||||
<Attribute
|
|
||||||
name='Created'
|
|
||||||
value={new Date(data.createdAt).toLocaleString()}
|
|
||||||
/>
|
|
||||||
<Attribute
|
|
||||||
name='Last Seen'
|
|
||||||
value={new Date(data.lastSeen).toLocaleString()}
|
|
||||||
/>
|
|
||||||
<Attribute
|
|
||||||
name='Expiry'
|
|
||||||
value={new Date(data.expiry).toLocaleString()}
|
|
||||||
/>
|
|
||||||
<Attribute
|
|
||||||
isCopyable
|
|
||||||
name='Domain'
|
|
||||||
value={`${data.givenName}.${data.user.name}.ts.net`}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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 machine’s permissions match its creator’s.
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 { type Dispatch, type SetStateAction } from 'react'
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog'
|
import Dialog from '~/components/Dialog'
|
||||||
import { type Machine } from '~/types'
|
import { type Machine } from '~/types'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
type DeleteProperties = {
|
interface DeleteProps {
|
||||||
readonly machine: Machine;
|
readonly machine: Machine
|
||||||
readonly fetcher: FetcherWithComponents<unknown>;
|
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||||
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 (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Panel control={state}>
|
<Dialog.Panel control={state}>
|
||||||
{close => (
|
{close => (
|
||||||
<>
|
<>
|
||||||
<Dialog.Title>
|
<Dialog.Title>
|
||||||
Remove {machine.givenName}
|
Remove
|
||||||
|
{' '}
|
||||||
|
{machine.givenName}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
This machine will be permanently removed from
|
This machine will be permanently removed from
|
||||||
your network. To re-add it, you will need to
|
your network. To re-add it, you will need to
|
||||||
reauthenticate to your tailnet from the device.
|
reauthenticate to your tailnet from the device.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<fetcher.Form method='POST'>
|
<Form
|
||||||
<input type='hidden' name='_method' value='delete'/>
|
method="POST"
|
||||||
<input type='hidden' name='id' value={machine.id}/>
|
onSubmit={(e) => {
|
||||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
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
|
<Dialog.Action
|
||||||
variant='cancel'
|
variant="cancel"
|
||||||
onPress={close}
|
onPress={close}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Dialog.Action>
|
</Dialog.Action>
|
||||||
<Dialog.Action
|
<Dialog.Action
|
||||||
variant='confirm'
|
variant="confirm"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-red-500 hover:border-red-700',
|
'bg-red-500 hover:border-red-700',
|
||||||
'dark:bg-red-600 dark:hover:border-red-700',
|
'dark:bg-red-600 dark:hover:border-red-700',
|
||||||
'pressed:bg-red-600 hover:bg-red-600',
|
'pressed:bg-red-600 hover:bg-red-600',
|
||||||
'text-white dark:text-white'
|
'text-white dark:text-white',
|
||||||
)}
|
)}
|
||||||
onPress={close}
|
onPress={close}
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</Dialog.Action>
|
</Dialog.Action>
|
||||||
</div>
|
</div>
|
||||||
</fetcher.Form>
|
</Form>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Dialog.Panel>
|
</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 { type Dispatch, type SetStateAction } from 'react'
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog'
|
import Dialog from '~/components/Dialog'
|
||||||
import { type Machine } from '~/types'
|
import { type Machine } from '~/types'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
type DeleteProperties = {
|
interface ExpireProps {
|
||||||
readonly machine: Machine;
|
readonly machine: Machine
|
||||||
readonly fetcher: FetcherWithComponents<unknown>;
|
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||||
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 (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Panel control={state}>
|
<Dialog.Panel control={state}>
|
||||||
{close => (
|
{close => (
|
||||||
<>
|
<>
|
||||||
<Dialog.Title>
|
<Dialog.Title>
|
||||||
Expire {machine.givenName}
|
Expire
|
||||||
|
{' '}
|
||||||
|
{machine.givenName}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
This will disconnect the machine from your Tailnet.
|
This will disconnect the machine from your Tailnet.
|
||||||
In order to reconnect, you will need to re-authenticate
|
In order to reconnect, you will need to re-authenticate
|
||||||
from the device.
|
from the device.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<fetcher.Form method='POST'>
|
<Form
|
||||||
<input type='hidden' name='_method' value='expire'/>
|
method="POST"
|
||||||
<input type='hidden' name='id' value={machine.id}/>
|
onSubmit={(e) => {
|
||||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
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
|
<Dialog.Action
|
||||||
variant='cancel'
|
variant="cancel"
|
||||||
onPress={close}
|
onPress={close}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Dialog.Action>
|
</Dialog.Action>
|
||||||
<Dialog.Action
|
<Dialog.Action
|
||||||
variant='confirm'
|
variant="confirm"
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-red-500 hover:border-red-700',
|
'bg-red-500 hover:border-red-700',
|
||||||
'dark:bg-red-600 dark:hover:border-red-700',
|
'dark:bg-red-600 dark:hover:border-red-700',
|
||||||
'pressed:bg-red-600 hover:bg-red-600',
|
'pressed:bg-red-600 hover:bg-red-600',
|
||||||
'text-white dark:text-white'
|
'text-white dark:text-white',
|
||||||
)}
|
)}
|
||||||
onPress={close}
|
onPress={close}
|
||||||
>
|
>
|
||||||
Expire
|
Expire
|
||||||
</Dialog.Action>
|
</Dialog.Action>
|
||||||
</div>
|
</div>
|
||||||
</fetcher.Form>
|
</Form>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Dialog.Panel>
|
</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 { type Dispatch, type SetStateAction, useState } from 'react'
|
||||||
|
|
||||||
import Code from '~/components/Code'
|
import Code from '~/components/Code'
|
||||||
@@ -6,15 +6,15 @@ import Dialog from '~/components/Dialog'
|
|||||||
import TextField from '~/components/TextField'
|
import TextField from '~/components/TextField'
|
||||||
import { type Machine } from '~/types'
|
import { type Machine } from '~/types'
|
||||||
|
|
||||||
type RenameProperties = {
|
interface RenameProps {
|
||||||
readonly machine: Machine;
|
readonly machine: Machine
|
||||||
readonly fetcher: FetcherWithComponents<unknown>;
|
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
readonly magic?: string
|
||||||
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 [name, setName] = useState(machine.givenName)
|
||||||
|
const submit = useSubmit()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
@@ -22,65 +22,76 @@ export default function Rename({ machine, fetcher, state, magic }: RenamePropert
|
|||||||
{close => (
|
{close => (
|
||||||
<>
|
<>
|
||||||
<Dialog.Title>
|
<Dialog.Title>
|
||||||
Edit machine name for {machine.givenName}
|
Edit machine name for
|
||||||
|
{' '}
|
||||||
|
{machine.givenName}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
This name is shown in the admin panel, in Tailscale clients,
|
This name is shown in the admin panel, in Tailscale clients,
|
||||||
and used when generating MagicDNS names.
|
and used when generating MagicDNS names.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<fetcher.Form method='POST'>
|
<Form
|
||||||
<input type='hidden' name='_method' value='rename'/>
|
method="POST"
|
||||||
<input type='hidden' name='id' value={machine.id}/>
|
onSubmit={(e) => {
|
||||||
|
submit(e.currentTarget)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="_method" value="rename" />
|
||||||
|
<input type="hidden" name="id" value={machine.id} />
|
||||||
<TextField
|
<TextField
|
||||||
label='Machine name'
|
label="Machine name"
|
||||||
placeholder='Machine name'
|
placeholder="Machine name"
|
||||||
name='name'
|
name="name"
|
||||||
state={[name, setName]}
|
state={[name, setName]}
|
||||||
className='my-2'
|
className="my-2"
|
||||||
/>
|
/>
|
||||||
{magic ? (
|
{magic
|
||||||
name.length > 0 && name !== machine.givenName ? (
|
? (
|
||||||
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'>
|
name.length > 0 && name !== machine.givenName
|
||||||
This machine will be accessible by the hostname
|
? (
|
||||||
{' '}
|
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||||
<Code className='text-sm'>
|
This machine will be accessible by the hostname
|
||||||
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
{' '}
|
||||||
</Code>
|
<Code className="text-sm">
|
||||||
{'. '}
|
{name.toLowerCase().replaceAll(/\s+/g, '-')}
|
||||||
The hostname
|
</Code>
|
||||||
{' '}
|
{'. '}
|
||||||
<Code className='text-sm'>
|
The hostname
|
||||||
{machine.givenName}
|
{' '}
|
||||||
</Code>
|
<Code className="text-sm">
|
||||||
{' '}
|
{machine.givenName}
|
||||||
will no longer point to this machine.
|
</Code>
|
||||||
</p>
|
{' '}
|
||||||
) : (
|
will no longer point to this machine.
|
||||||
<p className='text-sm text-gray-500 dark:text-gray-300 leading-tight'>
|
</p>
|
||||||
This machine is accessible by the hostname
|
)
|
||||||
{' '}
|
: (
|
||||||
<Code className='text-sm'>
|
<p className="text-sm text-gray-500 dark:text-gray-300 leading-tight">
|
||||||
{machine.givenName}
|
This machine is accessible by the hostname
|
||||||
</Code>
|
{' '}
|
||||||
.
|
<Code className="text-sm">
|
||||||
</p>
|
{machine.givenName}
|
||||||
)
|
</Code>
|
||||||
) : undefined}
|
.
|
||||||
<div className='mt-6 flex justify-end gap-2 mt-6'>
|
</p>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||||
<Dialog.Action
|
<Dialog.Action
|
||||||
variant='cancel'
|
variant="cancel"
|
||||||
onPress={close}
|
onPress={close}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Dialog.Action>
|
</Dialog.Action>
|
||||||
<Dialog.Action
|
<Dialog.Action
|
||||||
variant='confirm'
|
variant="confirm"
|
||||||
onPress={close}
|
onPress={close}
|
||||||
>
|
>
|
||||||
Rename
|
Rename
|
||||||
</Dialog.Action>
|
</Dialog.Action>
|
||||||
</div>
|
</div>
|
||||||
</fetcher.Form>
|
</Form>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
|||||||
@@ -1,57 +1,89 @@
|
|||||||
import { type FetcherWithComponents } from '@remix-run/react'
|
import { useFetcher } from '@remix-run/react'
|
||||||
import { type Dispatch, type SetStateAction } from 'react'
|
import { Dispatch, SetStateAction, useMemo } from 'react'
|
||||||
|
|
||||||
import Dialog from '~/components/Dialog'
|
import Dialog from '~/components/Dialog'
|
||||||
import Switch from '~/components/Switch'
|
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'
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
type RoutesProperties = {
|
interface RoutesProps {
|
||||||
readonly machine: Machine;
|
readonly machine: Machine
|
||||||
readonly routes: Route[];
|
readonly routes: Route[]
|
||||||
readonly fetcher: FetcherWithComponents<unknown>;
|
readonly state: [boolean, Dispatch<SetStateAction<boolean>>]
|
||||||
readonly state: [boolean, Dispatch<SetStateAction<boolean>>];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Support deleting routes
|
// 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 (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Panel control={state}>
|
<Dialog.Panel control={state}>
|
||||||
{close => (
|
{close => (
|
||||||
<>
|
<>
|
||||||
<Dialog.Title>
|
<Dialog.Title>
|
||||||
Edit route settings of {machine.givenName}
|
Edit route settings of
|
||||||
|
{' '}
|
||||||
|
{machine.givenName}
|
||||||
</Dialog.Title>
|
</Dialog.Title>
|
||||||
|
<Dialog.Text className="font-bold">
|
||||||
|
Subnet routes
|
||||||
|
</Dialog.Text>
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
Connect to devices you can't install Tailscale on
|
Connect to devices you can't install Tailscale on
|
||||||
by advertising IP ranges as subnet routes.
|
by advertising IP ranges as subnet routes.
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
to="https://tailscale.com/kb/1019/subnets"
|
||||||
|
name="Tailscale Subnets Documentation"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</Link>
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'rounded-lg overflow-y-auto my-2',
|
'rounded-lg overflow-y-auto my-2',
|
||||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
'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
|
<div
|
||||||
className={cn(
|
key={route.id}
|
||||||
'flex py-4 px-4 bg-ui-100 dark:bg-ui-800',
|
|
||||||
'items-center justify-center',
|
|
||||||
'text-ui-600 dark:text-ui-300'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<p>
|
|
||||||
No routes are advertised on this machine.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : undefined}
|
|
||||||
{routes.map(route => (
|
|
||||||
<div
|
|
||||||
key={route.node.id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
'flex py-2 px-4 bg-ui-100 dark:bg-ui-800',
|
||||||
'items-center justify-between'
|
'items-center justify-between',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<p>
|
<p>
|
||||||
@@ -59,8 +91,8 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
|
|||||||
</p>
|
</p>
|
||||||
<Switch
|
<Switch
|
||||||
defaultSelected={route.enabled}
|
defaultSelected={route.enabled}
|
||||||
label='Enabled'
|
label="Enabled"
|
||||||
onChange={checked => {
|
onChange={(checked) => {
|
||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.set('id', machine.id)
|
form.set('id', machine.id)
|
||||||
form.set('_method', 'routes')
|
form.set('_method', 'routes')
|
||||||
@@ -68,16 +100,76 @@ export default function Routes({ machine, routes, fetcher, state }: RoutesProper
|
|||||||
|
|
||||||
form.set('enabled', String(checked))
|
form.set('enabled', String(checked))
|
||||||
fetcher.submit(form, {
|
fetcher.submit(form, {
|
||||||
method: 'POST'
|
method: 'POST',
|
||||||
})
|
})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</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
|
<Dialog.Action
|
||||||
variant='cancel'
|
variant="cancel"
|
||||||
isDisabled={fetcher.state === 'submitting'}
|
isDisabled={fetcher.state === 'submitting'}
|
||||||
onPress={close}
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,71 +1,99 @@
|
|||||||
/* eslint-disable react/hook-use-state */
|
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react'
|
||||||
import { ChevronDownIcon, ClipboardIcon, EllipsisHorizontalIcon } from '@heroicons/react/24/outline'
|
import { Link } from '@remix-run/react'
|
||||||
import { type FetcherWithComponents, Link } from '@remix-run/react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
|
|
||||||
import Menu from '~/components/Menu'
|
import Menu from '~/components/Menu'
|
||||||
import StatusCircle from '~/components/StatusCircle'
|
import StatusCircle from '~/components/StatusCircle'
|
||||||
import { toast } from '~/components/Toaster'
|
import { toast } from '~/components/Toaster'
|
||||||
import { type Machine, type Route } from '~/types'
|
import { Machine, Route, User } from '~/types'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
|
|
||||||
import Delete from './dialogs/delete'
|
import MenuOptions from './menu'
|
||||||
import Expire from './dialogs/expire'
|
|
||||||
import Rename from './dialogs/rename'
|
|
||||||
import Routes from './dialogs/routes'
|
|
||||||
|
|
||||||
type MachineProperties = {
|
interface Props {
|
||||||
readonly machine: Machine;
|
readonly machine: Machine
|
||||||
readonly routes: Route[];
|
readonly routes: Route[]
|
||||||
readonly fetcher: FetcherWithComponents<unknown>;
|
readonly users: User[]
|
||||||
readonly magic?: string;
|
readonly magic?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MachineRow({ machine, routes, fetcher, magic }: MachineProperties) {
|
export default function MachineRow({ machine, routes, magic, users }: Props) {
|
||||||
const renameState = useState(false)
|
const expired = machine.expiry === '0001-01-01 00:00:00'
|
||||||
const expireState = useState(false)
|
|| machine.expiry === '0001-01-01T00:00:00Z'
|
||||||
const removeState = useState(false)
|
|| machine.expiry === null
|
||||||
const routesState = useState(false)
|
? false
|
||||||
|
: new Date(machine.expiry).getTime() < Date.now()
|
||||||
|
|
||||||
const expired = new Date(machine.expiry).getTime() < Date.now()
|
|
||||||
const tags = [
|
const tags = [
|
||||||
...machine.forcedTags,
|
...machine.forcedTags,
|
||||||
...machine.validTags
|
...machine.validTags,
|
||||||
]
|
]
|
||||||
|
|
||||||
if (expired) {
|
if (expired) {
|
||||||
tags.unshift('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 (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={machine.id}
|
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
|
<Link
|
||||||
to={`/machines/${machine.id}`}
|
to={`/machines/${machine.id}`}
|
||||||
className='group/link h-full'
|
className="group/link h-full"
|
||||||
>
|
>
|
||||||
<p className={cn(
|
<p className={cn(
|
||||||
'font-semibold leading-snug',
|
'font-semibold leading-snug',
|
||||||
'group-hover/link:text-blue-600',
|
'group-hover/link:text-blue-600',
|
||||||
'group-hover/link:dark:text-blue-400'
|
'group-hover/link:dark:text-blue-400',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{machine.givenName}
|
{machine.givenName}
|
||||||
</p>
|
</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}
|
{machine.name}
|
||||||
</p>
|
</p>
|
||||||
<div className='flex gap-1 mt-1'>
|
<div className="flex gap-1 mt-1">
|
||||||
{tags.map(tag => (
|
{tags.map(tag => (
|
||||||
<span
|
<span
|
||||||
key={tag}
|
key={tag}
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs rounded-sm px-1 py-0.5',
|
'text-xs rounded-md px-1.5 py-0.5',
|
||||||
'bg-gray-100 dark:bg-zinc-700',
|
'bg-ui-200 dark:bg-ui-800',
|
||||||
'text-gray-600 dark:text-gray-300'
|
'text-ui-600 dark:text-ui-300',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{tag}
|
{tag}
|
||||||
@@ -74,21 +102,21 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
<td className='py-2'>
|
<td className="py-2">
|
||||||
<div className='flex items-center gap-x-1'>
|
<div className="flex items-center gap-x-1">
|
||||||
{machine.ipAddresses[0]}
|
{machine.ipAddresses[0]}
|
||||||
<Menu>
|
<Menu>
|
||||||
<Menu.Button>
|
<Menu.Button>
|
||||||
<ChevronDownIcon className='w-4 h-4'/>
|
<ChevronDownIcon className="w-4 h-4" />
|
||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
<Menu.Items>
|
<Menu.Items>
|
||||||
{machine.ipAddresses.map(ip => (
|
{machine.ipAddresses.map(ip => (
|
||||||
<Menu.ItemButton
|
<Menu.ItemButton
|
||||||
key={ip}
|
key={ip}
|
||||||
type='button'
|
type="button"
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-x-1.5 text-sm',
|
'flex items-center gap-x-1.5 text-sm',
|
||||||
'justify-between w-full'
|
'justify-between w-full',
|
||||||
)}
|
)}
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await navigator.clipboard.writeText(ip)
|
await navigator.clipboard.writeText(ip)
|
||||||
@@ -96,108 +124,60 @@ export default function MachineRow({ machine, routes, fetcher, magic }: MachineP
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{ip}
|
{ip}
|
||||||
<ClipboardIcon className='w-3 h-3'/>
|
<CopyIcon className="w-3 h-3" />
|
||||||
</Menu.ItemButton>
|
</Menu.ItemButton>
|
||||||
))}
|
))}
|
||||||
{magic ? (
|
{magic
|
||||||
<Menu.ItemButton
|
? (
|
||||||
type='button'
|
<Menu.ItemButton
|
||||||
className={cn(
|
type="button"
|
||||||
'flex items-center gap-x-1.5 text-sm',
|
className={cn(
|
||||||
'justify-between w-full break-keep'
|
'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}`
|
onPress={async () => {
|
||||||
await navigator.clipboard.writeText(ip)
|
const ip = `${machine.givenName}.${prefix}`
|
||||||
toast('Copied hostname to clipboard')
|
await navigator.clipboard.writeText(ip)
|
||||||
}}
|
toast('Copied hostname to clipboard')
|
||||||
>
|
}}
|
||||||
{machine.givenName}.{machine.user.name}.{magic}
|
>
|
||||||
<ClipboardIcon className='w-3 h-3'/>
|
{machine.givenName}
|
||||||
</Menu.ItemButton>
|
.
|
||||||
) : undefined}
|
{prefix}
|
||||||
|
<CopyIcon className="w-3 h-3" />
|
||||||
|
</Menu.ItemButton>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
</Menu.Items>
|
</Menu.Items>
|
||||||
</Menu>
|
</Menu>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className='py-2'>
|
<td className="py-2">
|
||||||
<span className={cn(
|
<span className={cn(
|
||||||
'flex items-center gap-x-1 text-sm',
|
'flex items-center gap-x-1 text-sm',
|
||||||
'text-gray-500 dark:text-gray-400'
|
'text-gray-500 dark:text-gray-400',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<StatusCircle
|
<StatusCircle
|
||||||
isOnline={machine.online && !expired}
|
isOnline={machine.online && !expired}
|
||||||
className='w-4 h-4'
|
className="w-4 h-4"
|
||||||
/>
|
/>
|
||||||
<p>
|
<p>
|
||||||
{machine.online && !expired
|
{machine.online && !expired
|
||||||
? 'Connected'
|
? 'Connected'
|
||||||
: new Date(
|
: new Date(
|
||||||
machine.lastSeen
|
machine.lastSeen,
|
||||||
).toLocaleString()}
|
).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className='py-2 pr-0.5'>
|
<td className="py-2 pr-0.5">
|
||||||
<Rename
|
<MenuOptions
|
||||||
machine={machine}
|
|
||||||
fetcher={fetcher}
|
|
||||||
state={renameState}
|
|
||||||
magic={magic}
|
|
||||||
/>
|
|
||||||
<Delete
|
|
||||||
machine={machine}
|
|
||||||
fetcher={fetcher}
|
|
||||||
state={removeState}
|
|
||||||
/>
|
|
||||||
{expired ? undefined : (
|
|
||||||
<Expire
|
|
||||||
machine={machine}
|
|
||||||
fetcher={fetcher}
|
|
||||||
state={expireState}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Routes
|
|
||||||
machine={machine}
|
machine={machine}
|
||||||
routes={routes}
|
routes={routes}
|
||||||
fetcher={fetcher}
|
users={users}
|
||||||
state={routesState}
|
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'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<EllipsisHorizontalIcon className='w-5'/>
|
|
||||||
</Menu.Button>
|
|
||||||
<Menu.Items>
|
|
||||||
<Menu.ItemButton control={renameState}>
|
|
||||||
Edit machine name
|
|
||||||
</Menu.ItemButton>
|
|
||||||
<Menu.ItemButton control={routesState}>
|
|
||||||
Edit route settings
|
|
||||||
</Menu.ItemButton>
|
|
||||||
<Menu.Item className='opacity-50 hover:bg-transparent'>
|
|
||||||
Edit ACL tags
|
|
||||||
</Menu.Item>
|
|
||||||
{expired ? undefined : (
|
|
||||||
<Menu.ItemButton control={expireState}>
|
|
||||||
Expire
|
|
||||||
</Menu.ItemButton>
|
|
||||||
)}
|
|
||||||
<Menu.ItemButton
|
|
||||||
className='text-red-500 dark:text-red-400'
|
|
||||||
control={removeState}
|
|
||||||
>
|
|
||||||
Remove
|
|
||||||
</Menu.ItemButton>
|
|
||||||
</Menu.Items>
|
|
||||||
</Menu>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,160 +1,130 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
import { InformationCircleIcon } from '@heroicons/react/24/outline'
|
import { InfoIcon } from '@primer/octicons-react'
|
||||||
import { type ActionFunctionArgs, json, type LoaderFunctionArgs } from '@remix-run/node'
|
import { ActionFunctionArgs, LoaderFunctionArgs } from '@remix-run/node'
|
||||||
import { useFetcher, useLoaderData } from '@remix-run/react'
|
import { useLoaderData } from '@remix-run/react'
|
||||||
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
|
import { Button, Tooltip, TooltipTrigger } from 'react-aria-components'
|
||||||
|
|
||||||
import Code from '~/components/Code'
|
import Code from '~/components/Code'
|
||||||
import { type Machine, type Route } from '~/types'
|
import Link from '~/components/Link'
|
||||||
import { cn } from '~/utils/cn'
|
import { cn } from '~/utils/cn'
|
||||||
import { getConfig, getContext } from '~/utils/config'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import { del, post, pull } from '~/utils/headscale'
|
import { loadConfig } from '~/utils/config/headscale'
|
||||||
|
import { pull } from '~/utils/headscale'
|
||||||
import { getSession } from '~/utils/sessions'
|
import { getSession } from '~/utils/sessions'
|
||||||
import { useLiveData } from '~/utils/useLiveData'
|
import { useLiveData } from '~/utils/useLiveData'
|
||||||
|
import type { Machine, Route, User } from '~/types'
|
||||||
|
|
||||||
|
import { menuAction } from './action'
|
||||||
import MachineRow from './machine'
|
import MachineRow from './machine'
|
||||||
|
import NewMachine from './dialogs/new'
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
|
const [machines, routes, users] = await Promise.all([
|
||||||
|
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
||||||
|
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||||
|
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||||
|
])
|
||||||
|
|
||||||
const machines = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
|
const context = await loadContext()
|
||||||
const routes = await pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!)
|
|
||||||
const context = await getContext()
|
|
||||||
|
|
||||||
let magic: string | undefined
|
let magic: string | undefined
|
||||||
if (context.hasConfig) {
|
|
||||||
const config = await getConfig()
|
if (context.config.read) {
|
||||||
if (config.dns_config.magic_dns) {
|
const config = await loadConfig()
|
||||||
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 {
|
return {
|
||||||
nodes: machines.nodes,
|
nodes: machines.nodes,
|
||||||
routes: routes.routes,
|
routes: routes.routes,
|
||||||
magic
|
users: users.users,
|
||||||
|
magic,
|
||||||
|
server: context.headscaleUrl,
|
||||||
|
publicServer: context.headscalePublicUrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
return menuAction(request)
|
||||||
if (!session.has('hsApiKey')) {
|
|
||||||
return json({ message: 'Unauthorized' }, {
|
|
||||||
status: 401
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await request.formData()
|
|
||||||
if (!data.has('_method') || !data.has('id')) {
|
|
||||||
return json({ message: 'No method or ID provided' }, {
|
|
||||||
status: 400
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = String(data.get('id'))
|
|
||||||
const method = String(data.get('_method'))
|
|
||||||
|
|
||||||
switch (method) {
|
|
||||||
case 'delete': {
|
|
||||||
await del(`v1/node/${id}`, session.get('hsApiKey')!)
|
|
||||||
return json({ message: 'Machine removed' })
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'expire': {
|
|
||||||
console.log('expire')
|
|
||||||
const data = await post(`v1/node/${id}/expire`, session.get('hsApiKey')!)
|
|
||||||
console.log(data)
|
|
||||||
return json({ message: 'Machine expired' })
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'rename': {
|
|
||||||
if (!data.has('name')) {
|
|
||||||
return json({ message: 'No name provided' }, {
|
|
||||||
status: 400
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const name = String(data.get('name'))
|
|
||||||
|
|
||||||
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!)
|
|
||||||
return json({ message: 'Machine renamed' })
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'routes': {
|
|
||||||
if (!data.has('route') || !data.has('enabled')) {
|
|
||||||
return json({ message: 'No route or enabled provided' }, {
|
|
||||||
status: 400
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const route = String(data.get('route'))
|
|
||||||
const enabled = data.get('enabled') === 'true'
|
|
||||||
const postfix = enabled ? 'enable' : 'disable'
|
|
||||||
|
|
||||||
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!)
|
|
||||||
return json({ message: 'Route updated' })
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
return json({ message: 'Invalid method' }, {
|
|
||||||
status: 400
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
useLiveData({ interval: 3000 })
|
useLiveData({ interval: 3000 })
|
||||||
const data = useLoaderData<typeof loader>()
|
const data = useLoaderData<typeof loader>()
|
||||||
const fetcher = useFetcher()
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
|
<div className="flex justify-between items-center mb-8">
|
||||||
<table className='table-auto w-full rounded-lg'>
|
<div className="flex flex-col w-2/3">
|
||||||
<thead className='text-gray-500 dark:text-gray-400'>
|
<h1 className='text-2xl font-medium mb-4'>Machines</h1>
|
||||||
<tr className='text-left uppercase text-xs font-bold px-0.5'>
|
<p className='text-gray-700 dark:text-gray-300'>
|
||||||
<th className='pb-2'>Name</th>
|
Manage the devices connected to your Tailnet.
|
||||||
<th className='pb-2'>
|
{' '}
|
||||||
<div className='flex items-center gap-x-1'>
|
<Link
|
||||||
|
to="https://tailscale.com/kb/1372/manage-devices"
|
||||||
|
name="Tailscale Manage Devices Documentation"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<NewMachine
|
||||||
|
server={data.publicServer ?? data.server}
|
||||||
|
users={data.users}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<table className="table-auto w-full rounded-lg">
|
||||||
|
<thead className="text-gray-500 dark:text-gray-400">
|
||||||
|
<tr className="text-left uppercase text-xs font-bold px-0.5">
|
||||||
|
<th className="pb-2">Name</th>
|
||||||
|
<th className="pb-2">
|
||||||
|
<div className="flex items-center gap-x-1">
|
||||||
Addresses
|
Addresses
|
||||||
{data.magic ? (
|
{data.magic
|
||||||
<TooltipTrigger delay={0}>
|
? (
|
||||||
<Button>
|
<TooltipTrigger delay={0}>
|
||||||
<InformationCircleIcon className='w-4 h-4 text-gray-400'/>
|
<Button>
|
||||||
</Button>
|
<InfoIcon className="w-4 h-4" />
|
||||||
<Tooltip className={cn(
|
</Button>
|
||||||
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
<Tooltip className={cn(
|
||||||
'bg-white dark:bg-zinc-800',
|
'text-sm max-w-xs p-2 rounded-lg mb-2',
|
||||||
'border border-gray-200 dark:border-zinc-700'
|
'bg-white dark:bg-zinc-800',
|
||||||
)}
|
'border border-gray-200 dark:border-zinc-700',
|
||||||
>
|
)}
|
||||||
Since MagicDNS is enabled, you can access devices
|
>
|
||||||
based on their name and also at
|
Since MagicDNS is enabled, you can access devices
|
||||||
{' '}
|
based on their name and also at
|
||||||
<Code>
|
{' '}
|
||||||
[name].[user].{data.magic}
|
<Code>
|
||||||
</Code>
|
[name].
|
||||||
</Tooltip>
|
{data.magic}
|
||||||
</TooltipTrigger>
|
</Code>
|
||||||
) : undefined}
|
</Tooltip>
|
||||||
|
</TooltipTrigger>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th className='pb-2'>Last Seen</th>
|
<th className="pb-2">Last Seen</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className={cn(
|
<tbody className={cn(
|
||||||
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
'divide-y divide-zinc-200 dark:divide-zinc-700 align-top',
|
||||||
'border-t border-zinc-200 dark:border-zinc-700'
|
'border-t border-zinc-200 dark:border-zinc-700',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{data.nodes.map(machine => (
|
{data.nodes.map(machine => (
|
||||||
<MachineRow
|
<MachineRow
|
||||||
key={machine.id}
|
key={machine.id}
|
||||||
// Typescript isn't smart enough yet
|
machine={machine}
|
||||||
machine={machine as unknown as Machine}
|
routes={data.routes.filter(route => route.node.id === machine.id)}
|
||||||
routes={data.routes.filter(route => route.node.id === machine.id) as unknown as Route[]}
|
users={data.users}
|
||||||
fetcher={fetcher}
|
|
||||||
magic={data.magic}
|
magic={data.magic}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { CubeTransparentIcon } from '@heroicons/react/24/outline'
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return (
|
|
||||||
<div className='w-96 mx-auto flex flex-col justify-center items-center text-center'>
|
|
||||||
<CubeTransparentIcon className='w-32 h-32 text-gray-500'/>
|
|
||||||
<p className='text-lg mt-8'>
|
|
||||||
The settings page is currently unavailable.
|
|
||||||
It will be available in a future release.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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.headscalePublicUrl ?? 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,11 +1,11 @@
|
|||||||
import { Cog8ToothIcon, CpuChipIcon, GlobeAltIcon, LockClosedIcon, ServerStackIcon, UserCircleIcon, UsersIcon } from '@heroicons/react/24/outline'
|
|
||||||
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
|
import { type LoaderFunctionArgs, redirect } from '@remix-run/node'
|
||||||
import { Form, Outlet, useLoaderData } from '@remix-run/react'
|
import { Outlet, useLoaderData, useNavigation } from '@remix-run/react'
|
||||||
|
import { ProgressBar } from 'react-aria-components'
|
||||||
|
|
||||||
import { ErrorPopup } from '~/components/Error'
|
import { ErrorPopup } from '~/components/Error'
|
||||||
import Menu from '~/components/Menu'
|
import Header from '~/components/Header'
|
||||||
import TabLink from '~/components/TabLink'
|
import { cn } from '~/utils/cn'
|
||||||
import { getContext } from '~/utils/config'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import { HeadscaleError, pull } from '~/utils/headscale'
|
import { HeadscaleError, pull } from '~/utils/headscale'
|
||||||
import { destroySession, getSession } from '~/utils/sessions'
|
import { destroySession, getSession } from '~/utils/sessions'
|
||||||
|
|
||||||
@@ -20,13 +20,12 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
await pull('v1/apikey', session.get('hsApiKey')!)
|
await pull('v1/apikey', session.get('hsApiKey')!)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HeadscaleError) {
|
if (error instanceof HeadscaleError) {
|
||||||
console.error(error)
|
|
||||||
// Safest to just redirect to login if we can't pull
|
// Safest to just redirect to login if we can't pull
|
||||||
return redirect('/login', {
|
return redirect('/login', {
|
||||||
headers: {
|
headers: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
'Set-Cookie': await destroySession(session)
|
'Set-Cookie': await destroySession(session),
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,71 +33,33 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
throw error
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await getContext()
|
const context = await loadContext()
|
||||||
return {
|
return {
|
||||||
...context,
|
config: context.config,
|
||||||
user: session.get('user')
|
user: session.get('user'),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const data = useLoaderData<typeof loader>()
|
const data = useLoaderData<typeof loader>()
|
||||||
|
const nav = useNavigation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className='mb-6 bg-main-700 dark:bg-main-800 text-white'>
|
<ProgressBar
|
||||||
<nav className='container mx-auto'>
|
aria-label="Loading..."
|
||||||
<div className='flex items-center justify-between mb-8 pt-4'>
|
>
|
||||||
<div className='flex items-center gap-x-2'>
|
<div
|
||||||
<CpuChipIcon className='w-8 h-8'/>
|
className={cn(
|
||||||
<h1 className='text-2xl'>Headplane</h1>
|
'fixed top-0 left-0 z-50 w-1/2 h-1',
|
||||||
</div>
|
'bg-blue-500 dark:bg-blue-400 opacity-0',
|
||||||
<div className='flex items-center gap-x-4'>
|
nav.state === 'loading' && 'animate-loading opacity-100',
|
||||||
<a href='https://tailscale.com/download' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
|
)}
|
||||||
Download
|
/>
|
||||||
</a>
|
</ProgressBar>
|
||||||
<a href='https://github.com/tale/headplane' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
|
<Header data={data} />
|
||||||
GitHub
|
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||||
</a>
|
<Outlet />
|
||||||
<a href='https://github.com/juanfont/headscale' target='_blank' rel='noreferrer' className='text-gray-300 hover:text-white'>
|
|
||||||
Headscale
|
|
||||||
</a>
|
|
||||||
<Menu>
|
|
||||||
<Menu.Button>
|
|
||||||
<UserCircleIcon className='w-8 h-8'/>
|
|
||||||
</Menu.Button>
|
|
||||||
<Menu.Items>
|
|
||||||
<Menu.Item className='text-right'>
|
|
||||||
<p className='font-bold'>{data.user?.name}</p>
|
|
||||||
<p>{data.user?.email}</p>
|
|
||||||
</Menu.Item>
|
|
||||||
<Menu.Item className='text-red-500 dark:text-red-400'>
|
|
||||||
<Form method='POST' action='/logout'>
|
|
||||||
<button type='submit' className='w-full text-right'>
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
</Form>
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.Items>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-x-4'>
|
|
||||||
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>
|
|
||||||
<TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/>
|
|
||||||
{data.hasAcl ? <TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/> : undefined}
|
|
||||||
{data.hasConfig ? (
|
|
||||||
<>
|
|
||||||
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
|
|
||||||
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
|
|
||||||
</>
|
|
||||||
) : undefined}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main className='container mx-auto overscroll-contain mb-24'>
|
|
||||||
<Outlet/>
|
|
||||||
</main>
|
</main>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
@@ -107,22 +68,8 @@ export default function Layout() {
|
|||||||
export function ErrorBoundary() {
|
export function ErrorBoundary() {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className='mb-16 bg-main-700 dark:bg-main-800 text-white'>
|
<Header />
|
||||||
<nav className='container mx-auto'>
|
<ErrorPopup type="embedded" />
|
||||||
<div className='flex items-center gap-x-2 mb-8 pt-4'>
|
|
||||||
<CpuChipIcon className='w-8 h-8'/>
|
|
||||||
<h1 className='text-2xl'>Headplane</h1>
|
|
||||||
</div>
|
|
||||||
<div className='flex items-center gap-x-4'>
|
|
||||||
<TabLink to='/machines' name='Machines' icon={<ServerStackIcon className='w-5 h-5'/>}/>
|
|
||||||
<TabLink to='/users' name='Users' icon={<UsersIcon className='w-5 h-5'/>}/>
|
|
||||||
<TabLink to='/acls' name='Access Control' icon={<LockClosedIcon className='w-5 h-5'/>}/>
|
|
||||||
<TabLink to='/dns' name='DNS' icon={<GlobeAltIcon className='w-5 h-5'/>}/>
|
|
||||||
<TabLink to='/settings' name='Settings' icon={<Cog8ToothIcon className='w-5 h-5'/>}/>
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
</header>
|
|
||||||
<ErrorPopup type='embedded'/>
|
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
/* eslint-disable unicorn/filename-case */
|
|
||||||
import { ClipboardIcon, UserIcon } from '@heroicons/react/24/outline'
|
|
||||||
import { type LoaderFunctionArgs } from '@remix-run/node'
|
|
||||||
import { useLoaderData } from '@remix-run/react'
|
|
||||||
import { toast } from 'react-hot-toast/headless'
|
|
||||||
|
|
||||||
import Attribute from '~/components/Attribute'
|
|
||||||
import Card from '~/components/Card'
|
|
||||||
import StatusCircle from '~/components/StatusCircle'
|
|
||||||
import { type Machine, type User } from '~/types'
|
|
||||||
import { pull } from '~/utils/headscale'
|
|
||||||
import { getSession } from '~/utils/sessions'
|
|
||||||
import { useLiveData } from '~/utils/useLiveData'
|
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
const data = await pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!)
|
|
||||||
|
|
||||||
const users = new Map<string, Machine[]>()
|
|
||||||
for (const machine of data.nodes) {
|
|
||||||
const { user } = machine
|
|
||||||
if (!users.has(user.id)) {
|
|
||||||
users.set(user.id, [])
|
|
||||||
}
|
|
||||||
|
|
||||||
users.get(user.id)?.push(machine)
|
|
||||||
}
|
|
||||||
|
|
||||||
return [...users.values()].map(machines => {
|
|
||||||
const { user } = machines[0]
|
|
||||||
|
|
||||||
return {
|
|
||||||
...user,
|
|
||||||
machines
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const data = useLoaderData<typeof loader>()
|
|
||||||
useLiveData({ interval: 3000 })
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className='grid grid-cols-2 gap-4 auto-rows-min'>
|
|
||||||
{data.map(user => (
|
|
||||||
<Card key={user.id}>
|
|
||||||
<div className='flex items-center gap-4'>
|
|
||||||
<UserIcon className='w-6 h-6'/>
|
|
||||||
<span className='text-lg font-mono'>
|
|
||||||
{user.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className='py-4'>
|
|
||||||
{user.machines.map(machine => (
|
|
||||||
<div key={machine.id} className='flex items-center w-full gap-4'>
|
|
||||||
<StatusCircle isOnline={machine.online} className='w-4 h-4 px-1 w-fit'/>
|
|
||||||
<Attribute name={`Node ${machine.id}`} value={machine.givenName}/>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Form, useSubmit } from '@remix-run/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import Code from '~/components/Code'
|
||||||
|
import Dialog from '~/components/Dialog'
|
||||||
|
import TextField from '~/components/TextField'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
magic?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Add({ magic }: Props) {
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const submit = useSubmit()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog>
|
||||||
|
<Dialog.Button>
|
||||||
|
Add a new user
|
||||||
|
</Dialog.Button>
|
||||||
|
|
||||||
|
<Dialog.Panel>
|
||||||
|
{close => (
|
||||||
|
<>
|
||||||
|
<Dialog.Title>
|
||||||
|
Add a new user
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Text className="mb-8">
|
||||||
|
Enter a username to create a new user.
|
||||||
|
{' '}
|
||||||
|
{magic
|
||||||
|
? (
|
||||||
|
<>
|
||||||
|
Since Magic DNS is enabled, machines will be
|
||||||
|
accessible via
|
||||||
|
{' '}
|
||||||
|
<Code>
|
||||||
|
[machine].
|
||||||
|
.
|
||||||
|
{magic}
|
||||||
|
</Code>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
</Dialog.Text>
|
||||||
|
<Form
|
||||||
|
method="POST"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
submit(event.currentTarget)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="_method" value="create" />
|
||||||
|
<TextField
|
||||||
|
label="Username"
|
||||||
|
placeholder="my-new-user"
|
||||||
|
name="username"
|
||||||
|
state={[username, setUsername]}
|
||||||
|
className="my-2"
|
||||||
|
/>
|
||||||
|
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||||
|
<Dialog.Action
|
||||||
|
variant="cancel"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Action>
|
||||||
|
<Dialog.Action
|
||||||
|
variant="confirm"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { HomeIcon, PasskeyFillIcon } from '@primer/octicons-react'
|
||||||
|
|
||||||
|
import Card from '~/components/Card'
|
||||||
|
import Link from '~/components/Link'
|
||||||
|
|
||||||
|
import Add from './add'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
readonly magic: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Auth({ magic }: Props) {
|
||||||
|
return (
|
||||||
|
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||||
|
<div className="flex flex-col md:flex-row">
|
||||||
|
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
|
||||||
|
<HomeIcon className="w-5 h-5 mb-2" />
|
||||||
|
<h2 className="font-medium mb-1">
|
||||||
|
Basic Authentication
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||||
|
Users are not managed externally.
|
||||||
|
Using OpenID Connect can create a better
|
||||||
|
experience when using Headscale.
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
to="https://headscale.net/oidc"
|
||||||
|
name="Headscale OIDC Documentation"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
|
||||||
|
<PasskeyFillIcon className="w-5 h-5 mb-2" />
|
||||||
|
<h2 className="font-medium mb-1">
|
||||||
|
User Management
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||||
|
You can add, remove, and rename users here.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-4">
|
||||||
|
<Add magic={magic} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { OrganizationIcon, PasskeyFillIcon } from '@primer/octicons-react'
|
||||||
|
|
||||||
|
import Card from '~/components/Card'
|
||||||
|
import Link from '~/components/Link'
|
||||||
|
import { HeadplaneContext } from '~/utils/config/headplane'
|
||||||
|
|
||||||
|
import Add from './add'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
readonly oidc: NonNullable<HeadplaneContext['oidc']>
|
||||||
|
readonly magic: string | undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Oidc({ oidc, magic }: Props) {
|
||||||
|
return (
|
||||||
|
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||||
|
<div className="flex flex-col md:flex-row">
|
||||||
|
<div className="w-full p-4 border-b md:border-b-0 border-ui-200 dark:border-ui-700">
|
||||||
|
<OrganizationIcon className="w-5 h-5 mb-2" />
|
||||||
|
<h2 className="font-medium mb-1">
|
||||||
|
OpenID Connect
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||||
|
Users are managed through your
|
||||||
|
{' '}
|
||||||
|
<Link to={oidc.issuer} name="OIDC Provider">
|
||||||
|
OpenID Connect provider
|
||||||
|
</Link>
|
||||||
|
{'. '}
|
||||||
|
Groups and user information do not automatically sync.
|
||||||
|
{' '}
|
||||||
|
<Link
|
||||||
|
to="https://headscale.net/oidc"
|
||||||
|
name="Headscale OIDC Documentation"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="w-full p-4 md:border-l border-ui-200 dark:border-ui-700">
|
||||||
|
<PasskeyFillIcon className="w-5 h-5 mb-2" />
|
||||||
|
<h2 className="font-medium mb-1">
|
||||||
|
User Management
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-ui-600 dark:text-ui-300">
|
||||||
|
You can still add users manually, however it is recommended
|
||||||
|
that you manage users through your OIDC provider.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-2 mt-4">
|
||||||
|
<Add magic={magic} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { XIcon } from '@primer/octicons-react'
|
||||||
|
import { Form, useSubmit } from '@remix-run/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import Button from '~/components/Button'
|
||||||
|
import Code from '~/components/Code'
|
||||||
|
import Dialog from '~/components/Dialog'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
username: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Remove({ username }: Props) {
|
||||||
|
const submit = useSubmit()
|
||||||
|
const dialogState = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
control={dialogState}
|
||||||
|
className="rounded-full p-0 w-8 h-8"
|
||||||
|
>
|
||||||
|
<XIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Dialog control={dialogState}>
|
||||||
|
<Dialog.Panel control={dialogState}>
|
||||||
|
{close => (
|
||||||
|
<>
|
||||||
|
<Dialog.Title>
|
||||||
|
Delete
|
||||||
|
{' '}
|
||||||
|
{username}
|
||||||
|
?
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Text className="mb-8">
|
||||||
|
Are you sure you want to delete
|
||||||
|
{' '}
|
||||||
|
{username}
|
||||||
|
?
|
||||||
|
{' '}
|
||||||
|
A deleted user cannot be recovered.
|
||||||
|
</Dialog.Text>
|
||||||
|
<Form
|
||||||
|
method="POST"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
submit(event.currentTarget)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="_method" value="delete" />
|
||||||
|
<input type="hidden" name="username" value={username} />
|
||||||
|
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||||
|
<Dialog.Action
|
||||||
|
variant="cancel"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Action>
|
||||||
|
<Dialog.Action
|
||||||
|
variant="confirm"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { PencilIcon } from '@primer/octicons-react'
|
||||||
|
import { Form, useSubmit } from '@remix-run/react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import Button from '~/components/Button'
|
||||||
|
import Dialog from '~/components/Dialog'
|
||||||
|
import TextField from '~/components/TextField'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
username: string
|
||||||
|
magic?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Rename({ username, magic }: Props) {
|
||||||
|
const submit = useSubmit()
|
||||||
|
const dialogState = useState(false)
|
||||||
|
const [newName, setNewName] = useState(username)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
control={dialogState}
|
||||||
|
className="rounded-full p-0 w-8 h-8"
|
||||||
|
>
|
||||||
|
<PencilIcon className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Dialog control={dialogState}>
|
||||||
|
<Dialog.Panel control={dialogState}>
|
||||||
|
{close => (
|
||||||
|
<>
|
||||||
|
<Dialog.Title>
|
||||||
|
Rename
|
||||||
|
{' '}
|
||||||
|
{username}
|
||||||
|
?
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Text className="mb-8">
|
||||||
|
Enter a new username for
|
||||||
|
{' '}
|
||||||
|
{username}
|
||||||
|
</Dialog.Text>
|
||||||
|
<Form
|
||||||
|
method="POST"
|
||||||
|
onSubmit={(event) => {
|
||||||
|
submit(event.currentTarget)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input type="hidden" name="_method" value="rename" />
|
||||||
|
<input type="hidden" name="old" value={username} />
|
||||||
|
<TextField
|
||||||
|
label="Username"
|
||||||
|
placeholder="my-new-name"
|
||||||
|
name="new"
|
||||||
|
state={[newName, setNewName]}
|
||||||
|
className="my-2"
|
||||||
|
/>
|
||||||
|
<div className="mt-6 flex justify-end gap-2 mt-6">
|
||||||
|
<Dialog.Action
|
||||||
|
variant="cancel"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Dialog.Action>
|
||||||
|
<Dialog.Action
|
||||||
|
variant="confirm"
|
||||||
|
onPress={close}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</Dialog.Action>
|
||||||
|
</div>
|
||||||
|
</Form>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,352 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-non-null-assertion */
|
||||||
|
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core'
|
||||||
|
import { PersonIcon } from '@primer/octicons-react'
|
||||||
|
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'
|
||||||
|
|
||||||
|
import Attribute from '~/components/Attribute'
|
||||||
|
import Card from '~/components/Card'
|
||||||
|
import StatusCircle from '~/components/StatusCircle'
|
||||||
|
import { toast } from '~/components/Toaster'
|
||||||
|
import { Machine, User } from '~/types'
|
||||||
|
import { cn } from '~/utils/cn'
|
||||||
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
|
import { loadConfig } from '~/utils/config/headscale'
|
||||||
|
import { del, post, pull } from '~/utils/headscale'
|
||||||
|
import { getSession } from '~/utils/sessions'
|
||||||
|
import { useLiveData } from '~/utils/useLiveData'
|
||||||
|
|
||||||
|
import Auth from './auth'
|
||||||
|
import Oidc from './oidc'
|
||||||
|
import Remove from './remove'
|
||||||
|
import Rename from './rename'
|
||||||
|
|
||||||
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
|
|
||||||
|
const [machines, apiUsers] = await Promise.all([
|
||||||
|
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
||||||
|
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||||
|
])
|
||||||
|
|
||||||
|
const users = apiUsers.users.map(user => ({
|
||||||
|
...user,
|
||||||
|
machines: machines.nodes.filter(machine => machine.user.id === user.id),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const context = await loadContext()
|
||||||
|
let magic: string | undefined
|
||||||
|
|
||||||
|
if (context.config.read) {
|
||||||
|
const config = await loadConfig()
|
||||||
|
if (config.dns.magic_dns) {
|
||||||
|
magic = config.dns.base_domain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
oidc: context.oidc,
|
||||||
|
magic,
|
||||||
|
users,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
|
const session = await getSession(request.headers.get('Cookie'))
|
||||||
|
if (!session.has('hsApiKey')) {
|
||||||
|
return json({ message: 'Unauthorized' }, {
|
||||||
|
status: 401,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await request.formData()
|
||||||
|
if (!data.has('_method')) {
|
||||||
|
return json({ message: 'No method provided' }, {
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = String(data.get('_method'))
|
||||||
|
|
||||||
|
switch (method) {
|
||||||
|
case 'create': {
|
||||||
|
if (!data.has('username')) {
|
||||||
|
return json({ message: 'No name provided' }, {
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = String(data.get('username'))
|
||||||
|
await post('v1/user', session.get('hsApiKey')!, {
|
||||||
|
name: username,
|
||||||
|
})
|
||||||
|
|
||||||
|
return json({ message: `User ${username} created` })
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'delete': {
|
||||||
|
if (!data.has('username')) {
|
||||||
|
return json({ message: 'No name provided' }, {
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = String(data.get('username'))
|
||||||
|
await del(`v1/user/${username}`, session.get('hsApiKey')!)
|
||||||
|
return json({ message: `User ${username} deleted` })
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'rename': {
|
||||||
|
if (!data.has('old') || !data.has('new')) {
|
||||||
|
return json({ message: 'No old or new name provided' }, {
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const old = String(data.get('old'))
|
||||||
|
const newName = String(data.get('new'))
|
||||||
|
await post(`v1/user/${old}/rename/${newName}`, session.get('hsApiKey')!)
|
||||||
|
return json({ message: `User ${old} renamed to ${newName}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'move': {
|
||||||
|
if (!data.has('id') || !data.has('to') || !data.has('name')) {
|
||||||
|
return json({ message: 'No ID or destination provided' }, {
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = String(data.get('id'))
|
||||||
|
const to = String(data.get('to'))
|
||||||
|
const name = String(data.get('name'))
|
||||||
|
|
||||||
|
try {
|
||||||
|
await post(`v1/node/${id}/user?user=${to}`, session.get('hsApiKey')!)
|
||||||
|
return json({ message: `Moved ${name} to ${to}` })
|
||||||
|
} catch {
|
||||||
|
return json({ message: `Failed to move ${name} to ${to}` }, {
|
||||||
|
status: 500,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
default: {
|
||||||
|
return json({ message: 'Invalid method' }, {
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const data = useLoaderData<typeof loader>()
|
||||||
|
const [users, setUsers] = useState(data.users)
|
||||||
|
const actionData = useActionData<typeof action>()
|
||||||
|
useLiveData({ interval: 3000 })
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!actionData) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
toast(actionData.message)
|
||||||
|
if (actionData.message.startsWith('Failed')) {
|
||||||
|
setUsers(data.users)
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [actionData])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUsers(data.users)
|
||||||
|
}, [data.users])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<h1 className="text-2xl font-medium mb-1.5">
|
||||||
|
Users
|
||||||
|
</h1>
|
||||||
|
<p className="mb-8 text-md">
|
||||||
|
Manage the users in your network and their permissions.
|
||||||
|
Tip: You can drag machines between users to change ownership.
|
||||||
|
</p>
|
||||||
|
{data.oidc
|
||||||
|
? (
|
||||||
|
<Oidc
|
||||||
|
oidc={data.oidc}
|
||||||
|
magic={data.magic}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
: (
|
||||||
|
<Auth magic={data.magic} />
|
||||||
|
)}
|
||||||
|
<ClientOnly fallback={
|
||||||
|
<Users users={users} />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{() => (
|
||||||
|
<InteractiveUsers
|
||||||
|
users={users}
|
||||||
|
setUsers={setUsers}
|
||||||
|
magic={data.magic}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</ClientOnly>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserMachine = User & { machines: Machine[] }
|
||||||
|
|
||||||
|
interface UserProps {
|
||||||
|
users: UserMachine[]
|
||||||
|
setUsers?: (users: UserMachine[]) => void
|
||||||
|
magic?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function Users({ users, magic }: UserProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||||
|
{users.map(user => (
|
||||||
|
<UserCard
|
||||||
|
key={user.id}
|
||||||
|
user={user}
|
||||||
|
magic={magic}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function InteractiveUsers({ users, setUsers, magic }: UserProps) {
|
||||||
|
const submit = useSubmit()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext onDragEnd={(event) => {
|
||||||
|
const { over, active } = event
|
||||||
|
if (!over) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the UI optimistically
|
||||||
|
const newUsers = new Array<UserMachine>()
|
||||||
|
const reference = active.data as DataRef<Machine>
|
||||||
|
if (!reference.current) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ignore if the user is unchanged
|
||||||
|
if (reference.current.user.name === over.id) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const user of users) {
|
||||||
|
newUsers.push({
|
||||||
|
...user,
|
||||||
|
machines: over.id === user.name
|
||||||
|
? [...user.machines, reference.current]
|
||||||
|
: user.machines.filter(m => m.id !== active.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setUsers?.(newUsers)
|
||||||
|
const data = new FormData()
|
||||||
|
data.append('_method', 'move')
|
||||||
|
data.append('id', active.id.toString())
|
||||||
|
data.append('to', over.id.toString())
|
||||||
|
data.append('name', reference.current.givenName)
|
||||||
|
|
||||||
|
submit(data, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||||
|
{users.map(user => (
|
||||||
|
<UserCard
|
||||||
|
key={user.id}
|
||||||
|
user={user}
|
||||||
|
magic={magic}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MachineChip({ machine }: { readonly machine: Machine }) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||||
|
id: machine.id,
|
||||||
|
data: machine,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center w-full gap-2 py-1',
|
||||||
|
'hover:bg-ui-100 dark:hover:bg-ui-800 rounded-lg',
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: transform
|
||||||
|
? `translate3d(${transform.x.toString()}px, ${transform.y.toString()}px, 0)`
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
{...listeners}
|
||||||
|
{...attributes}
|
||||||
|
>
|
||||||
|
<StatusCircle isOnline={machine.online} className="w-4 h-4 px-1 w-fit" />
|
||||||
|
<Attribute
|
||||||
|
name={machine.givenName}
|
||||||
|
link={`machines/${machine.id}`}
|
||||||
|
value={machine.ipAddresses[0]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CardProps {
|
||||||
|
user: UserMachine
|
||||||
|
magic?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function UserCard({ user, magic }: CardProps) {
|
||||||
|
const { isOver, setNodeRef } = useDroppable({
|
||||||
|
id: user.name,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef}>
|
||||||
|
<Card
|
||||||
|
variant="flat"
|
||||||
|
className={cn(
|
||||||
|
'max-w-full w-full overflow-visible h-full',
|
||||||
|
isOver ? 'bg-ui-100 dark:bg-ui-800' : '',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<PersonIcon className="w-6 h-6" />
|
||||||
|
<span className="text-lg font-mono">
|
||||||
|
{user.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Rename username={user.name} magic={magic} />
|
||||||
|
{user.machines.length === 0
|
||||||
|
? (
|
||||||
|
<Remove username={user.name} />
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
{user.machines.map(machine => (
|
||||||
|
<MachineChip key={machine.id} machine={machine} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
|
import { HeadscaleError, pull } from '~/utils/headscale'
|
||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
const context = await loadContext()
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Doesn't matter, we just need a 401
|
||||||
|
await pull('v1/', 'wrongkey')
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof HeadscaleError)) {
|
||||||
|
log.debug('Healthz', 'Headscale is not reachable')
|
||||||
|
return new Response('Headscale is not reachable', {
|
||||||
|
status: 500,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Response('OK', {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/plain',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -5,9 +5,9 @@ import { useMemo } from 'react'
|
|||||||
import Button from '~/components/Button'
|
import Button from '~/components/Button'
|
||||||
import Card from '~/components/Card'
|
import Card from '~/components/Card'
|
||||||
import Code from '~/components/Code'
|
import Code from '~/components/Code'
|
||||||
import Input from '~/components/Input'
|
import TextField from '~/components/TextField'
|
||||||
import { type Key } from '~/types'
|
import { type Key } from '~/types'
|
||||||
import { getContext } from '~/utils/config'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import { pull } from '~/utils/headscale'
|
import { pull } from '~/utils/headscale'
|
||||||
import { startOidc } from '~/utils/oidc'
|
import { startOidc } from '~/utils/oidc'
|
||||||
import { commitSession, getSession } from '~/utils/sessions'
|
import { commitSession, getSession } from '~/utils/sessions'
|
||||||
@@ -18,36 +18,22 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
|||||||
return redirect('/machines', {
|
return redirect('/machines', {
|
||||||
headers: {
|
headers: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
'Set-Cookie': await commitSession(session)
|
'Set-Cookie': await commitSession(session),
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = await getContext()
|
const context = await loadContext()
|
||||||
const issuer = context.oidcConfig?.issuer
|
|
||||||
const id = context.oidcConfig?.client
|
|
||||||
const secret = context.oidcConfig?.secret
|
|
||||||
const normal = process.env.DISABLE_API_KEY_LOGIN
|
|
||||||
|
|
||||||
if (issuer && (!id || !secret)) {
|
// Only set if OIDC is properly enabled anyways
|
||||||
throw new Error('An invalid OIDC configuration was provided')
|
if (context.oidc?.disableKeyLogin) {
|
||||||
|
return startOidc(context.oidc, request)
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = {
|
return {
|
||||||
oidc: issuer,
|
oidc: context.oidc?.issuer,
|
||||||
apiKey: normal === undefined
|
apiKey: !context.oidc?.disableKeyLogin,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.oidc && !data.apiKey) {
|
|
||||||
throw new Error('No authentication method is enabled')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (data.oidc && !data.apiKey) {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
return startOidc(data.oidc, id!, request)
|
|
||||||
}
|
|
||||||
|
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({ request }: ActionFunctionArgs) {
|
export async function action({ request }: ActionFunctionArgs) {
|
||||||
@@ -55,13 +41,14 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
const oidcStart = formData.get('oidc-start')
|
const oidcStart = formData.get('oidc-start')
|
||||||
|
|
||||||
if (oidcStart) {
|
if (oidcStart) {
|
||||||
const context = await getContext()
|
const context = await loadContext()
|
||||||
const issuer = context.oidcConfig?.issuer
|
|
||||||
const id = context.oidcConfig?.client
|
if (!context.oidc) {
|
||||||
|
throw new Error('An invalid OIDC configuration was provided')
|
||||||
|
}
|
||||||
|
|
||||||
// We know it exists here because this action only happens on OIDC
|
// We know it exists here because this action only happens on OIDC
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
return startOidc(context.oidc, request)
|
||||||
return startOidc(issuer!, id!, request)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = String(formData.get('api-key'))
|
const apiKey = String(formData.get('api-key'))
|
||||||
@@ -82,21 +69,20 @@ export async function action({ request }: ActionFunctionArgs) {
|
|||||||
session.set('hsApiKey', apiKey)
|
session.set('hsApiKey', apiKey)
|
||||||
session.set('user', {
|
session.set('user', {
|
||||||
name: key.prefix,
|
name: key.prefix,
|
||||||
email: `${expiresDays} days`
|
email: `${expiresDays.toString()} days`,
|
||||||
})
|
})
|
||||||
|
|
||||||
return redirect('/machines', {
|
return redirect('/machines', {
|
||||||
headers: {
|
headers: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||||
'Set-Cookie': await commitSession(session, {
|
'Set-Cookie': await commitSession(session, {
|
||||||
maxAge: expiresIn
|
maxAge: expiresIn,
|
||||||
})
|
}),
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.error(error)
|
|
||||||
return json({
|
return json({
|
||||||
error: 'Invalid API key'
|
error: 'Invalid API key',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -107,62 +93,70 @@ export default function Page() {
|
|||||||
const showOr = useMemo(() => data.oidc && data.apiKey, [data])
|
const showOr = useMemo(() => data.oidc && data.apiKey, [data])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex min-h-screen items-center justify-center'>
|
<div className="flex min-h-screen items-center justify-center">
|
||||||
<Card className='w-96'>
|
<Card className="max-w-sm m-4 sm:m-0 rounded-2xl">
|
||||||
<h1 className='text-2xl mb-8'>Login</h1>
|
<Card.Title>
|
||||||
{data.apiKey ? (
|
Welcome to Headplane
|
||||||
<Form method='post'>
|
</Card.Title>
|
||||||
<p className='text-sm text-gray-500 mb-4'>
|
{data.apiKey
|
||||||
Enter an API key to authenticate with Headplane. You can generate
|
? (
|
||||||
one by running
|
<Form method="post">
|
||||||
{' '}
|
<Card.Text className="mb-8 text-sm">
|
||||||
<Code>
|
Enter an API key to authenticate with Headplane. You can generate
|
||||||
headscale apikeys create
|
one by running
|
||||||
</Code>
|
{' '}
|
||||||
{' '}
|
<Code>
|
||||||
in your terminal.
|
headscale apikeys create
|
||||||
</p>
|
</Code>
|
||||||
|
{' '}
|
||||||
|
in your terminal.
|
||||||
|
</Card.Text>
|
||||||
|
|
||||||
{actionData?.error ? (
|
{actionData?.error
|
||||||
<p className='text-red-500 text-sm mb-2'>{actionData.error}</p>
|
? (
|
||||||
) : undefined}
|
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
|
||||||
<Input
|
)
|
||||||
required
|
: undefined}
|
||||||
type='text'
|
<TextField
|
||||||
name='api-key'
|
isRequired
|
||||||
id='api-key'
|
label="API Key"
|
||||||
className='border rounded-md p-2 w-full'
|
name="api-key"
|
||||||
placeholder='API Key'
|
placeholder="API Key"
|
||||||
/>
|
type="password"
|
||||||
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant='emphasized'
|
className="w-full mt-2.5"
|
||||||
type='submit'
|
variant="heavy"
|
||||||
className='bg-gray-800 text-white rounded-md p-2 w-full mt-4'
|
type="submit"
|
||||||
>
|
>
|
||||||
Login
|
Login
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
) : undefined}
|
)
|
||||||
{showOr ? (
|
: undefined}
|
||||||
<div className='flex items-center gap-x-2 py-2'>
|
{showOr
|
||||||
<hr className='flex-1 dark:border-zinc-700'/>
|
? (
|
||||||
<span className='text-gray-500'>or</span>
|
<div className="flex items-center gap-x-1.5 py-1">
|
||||||
<hr className='flex-1 dark:border-zinc-700'/>
|
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
|
||||||
</div>
|
<span className="text-gray-500 text-sm">or</span>
|
||||||
) : undefined}
|
<hr className="flex-1 border-ui-300 dark:border-ui-800" />
|
||||||
{data.oidc ? (
|
</div>
|
||||||
<Form method='POST'>
|
)
|
||||||
<input type='hidden' name='oidc-start' value='true'/>
|
: undefined}
|
||||||
<Button
|
{data.oidc
|
||||||
variant='emphasized'
|
? (
|
||||||
type='submit'
|
<Form method="POST">
|
||||||
className='bg-gray-800 text-white rounded-md p-2 w-full'
|
<input type="hidden" name="oidc-start" value="true" />
|
||||||
>
|
<Button
|
||||||
Login with SSO
|
className="w-full"
|
||||||
</Button>
|
variant="heavy"
|
||||||
</Form>
|
type="submit"
|
||||||
) : undefined}
|
>
|
||||||
|
Login with SSO
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
)
|
||||||
|
: undefined}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
import { type LoaderFunctionArgs } from '@remix-run/node'
|
import { type LoaderFunctionArgs } from '@remix-run/node'
|
||||||
|
|
||||||
import { getContext } from '~/utils/config'
|
import { loadContext } from '~/utils/config/headplane'
|
||||||
import { finishOidc } from '~/utils/oidc'
|
import { finishOidc } from '~/utils/oidc'
|
||||||
|
|
||||||
export async function loader({ request }: LoaderFunctionArgs) {
|
export async function loader({ request }: LoaderFunctionArgs) {
|
||||||
const context = await getContext()
|
const context = await loadContext()
|
||||||
const oidc = context.oidcConfig
|
if (!context.oidc) {
|
||||||
|
|
||||||
if (!oidc) {
|
|
||||||
throw new Error('An invalid OIDC configuration was provided')
|
throw new Error('An invalid OIDC configuration was provided')
|
||||||
}
|
}
|
||||||
|
|
||||||
return finishOidc(oidc.issuer, oidc.client, oidc.secret, request)
|
return finishOidc(context.oidc, request)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,30 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@supports (scrollbar-gutter: stable) {
|
||||||
|
html {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,28 +1,28 @@
|
|||||||
import type { User } from './User'
|
import type { User } from './User'
|
||||||
|
|
||||||
export type Machine = {
|
export interface Machine {
|
||||||
id: string;
|
id: string
|
||||||
machineKey: string;
|
machineKey: string
|
||||||
nodeKey: string;
|
nodeKey: string
|
||||||
discoKey: string;
|
discoKey: string
|
||||||
ipAddresses: string[];
|
ipAddresses: string[]
|
||||||
name: string;
|
name: string
|
||||||
|
|
||||||
user: User;
|
user: User
|
||||||
lastSeen: Date;
|
lastSeen: string
|
||||||
expiry: Date;
|
expiry: string
|
||||||
|
|
||||||
preAuthKey?: unknown; // TODO
|
preAuthKey?: unknown // TODO
|
||||||
|
|
||||||
createdAt: Date;
|
createdAt: string
|
||||||
registerMethod: 'REGISTER_METHOD_UNSPECIFIED'
|
registerMethod: 'REGISTER_METHOD_UNSPECIFIED'
|
||||||
| 'REGISTER_METHOD_AUTH_KEY'
|
| 'REGISTER_METHOD_AUTH_KEY'
|
||||||
| 'REGISTER_METHOD_CLI'
|
| 'REGISTER_METHOD_CLI'
|
||||||
| 'REGISTER_METHOD_OIDC';
|
| 'REGISTER_METHOD_OIDC'
|
||||||
|
|
||||||
forcedTags: string[];
|
forcedTags: string[]
|
||||||
invalidTags: string[];
|
invalidTags: string[]
|
||||||
validTags: string[];
|
validTags: string[]
|
||||||
givenName: string;
|
givenName: string
|
||||||
online: boolean;
|
online: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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[]
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import type { Machine } from './Machine'
|
import type { Machine } from './Machine'
|
||||||
|
|
||||||
export type Route = {
|
export interface Route {
|
||||||
id: string;
|
id: string
|
||||||
node: Machine;
|
node: Machine
|
||||||
prefix: string;
|
prefix: string
|
||||||
advertised: boolean;
|
advertised: boolean
|
||||||
enabled: boolean;
|
enabled: boolean
|
||||||
isPrimary: boolean;
|
isPrimary: boolean
|
||||||
createdAt: Date;
|
createdAt: string
|
||||||
updatedAt: Date;
|
updatedAt: string
|
||||||
deletedAt: Date;
|
deletedAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export type User = {
|
export interface User {
|
||||||
id: string;
|
id: string
|
||||||
name: string;
|
name: string
|
||||||
createdAt: Date;
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,3 +2,4 @@ export * from './Key'
|
|||||||
export * from './Machine'
|
export * from './Machine'
|
||||||
export * from './Route'
|
export * from './Route'
|
||||||
export * from './User'
|
export * from './User'
|
||||||
|
export * from './PreAuthKey'
|
||||||
|
|||||||
@@ -1,355 +0,0 @@
|
|||||||
import { type FSWatcher, watch } from 'node:fs'
|
|
||||||
import { access, constants, readFile, writeFile } from 'node:fs/promises'
|
|
||||||
import { resolve } from 'node:path'
|
|
||||||
|
|
||||||
import { type Document, parse, parseDocument } from 'yaml'
|
|
||||||
|
|
||||||
type Duration = `${string}s` | `${string}h` | `${string}m` | `${string}d` | `${string}y`
|
|
||||||
|
|
||||||
type Config = {
|
|
||||||
server_url: string;
|
|
||||||
listen_addr: string;
|
|
||||||
metrics_listen_addr: string;
|
|
||||||
grpc_listen_addr: string;
|
|
||||||
grpc_allow_insecure: boolean;
|
|
||||||
|
|
||||||
private_key_path: string;
|
|
||||||
noise: {
|
|
||||||
private_key_path: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
prefixes: {
|
|
||||||
v4: string;
|
|
||||||
v6: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
derp: {
|
|
||||||
server: {
|
|
||||||
enabled: boolean;
|
|
||||||
region_id: number;
|
|
||||||
region_code: string;
|
|
||||||
region_name: string;
|
|
||||||
stun_listen_addr: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
urls: string[];
|
|
||||||
paths: string[];
|
|
||||||
auto_update_enabled: boolean;
|
|
||||||
update_frequency: Duration;
|
|
||||||
};
|
|
||||||
|
|
||||||
disable_check_updates: boolean;
|
|
||||||
epheremal_node_inactivity_timeout: Duration;
|
|
||||||
node_update_check_interval: Duration;
|
|
||||||
|
|
||||||
// Database is probably dangerous
|
|
||||||
database: {
|
|
||||||
type: 'sqlite3' | 'sqlite' | 'postgres';
|
|
||||||
sqlite?: {
|
|
||||||
path: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
postgres?: {
|
|
||||||
host: string;
|
|
||||||
port: number;
|
|
||||||
name: string;
|
|
||||||
user: string;
|
|
||||||
pass: string;
|
|
||||||
max_open_conns: number;
|
|
||||||
max_idle_conns: number;
|
|
||||||
conn_max_idle_time_secs: number;
|
|
||||||
ssl: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
acme_url: string;
|
|
||||||
acme_email: string;
|
|
||||||
tls_letsencrypt_hostname: string;
|
|
||||||
tls_letsencrypt_cache_dir: string;
|
|
||||||
tls_letsencrypt_challenge_type: string;
|
|
||||||
tls_letsencrypt_listen: string;
|
|
||||||
tls_cert_path: string;
|
|
||||||
tls_key_path: string;
|
|
||||||
|
|
||||||
log: {
|
|
||||||
format: 'text' | 'json';
|
|
||||||
level: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
acl_policy_path: string;
|
|
||||||
dns_config: {
|
|
||||||
override_local_dns: boolean;
|
|
||||||
nameservers: string[];
|
|
||||||
restricted_nameservers: Record<string, string[]>; // Split DNS
|
|
||||||
domains: string[];
|
|
||||||
extra_records: Array<{
|
|
||||||
name: string;
|
|
||||||
type: 'A';
|
|
||||||
value: string;
|
|
||||||
}>;
|
|
||||||
|
|
||||||
magic_dns: boolean;
|
|
||||||
base_domain: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
unix_socket: string;
|
|
||||||
unix_socket_permission: string;
|
|
||||||
|
|
||||||
oidc: {
|
|
||||||
only_start_if_oidc_is_available: boolean;
|
|
||||||
issuer: string;
|
|
||||||
client_id: string;
|
|
||||||
client_secret: string;
|
|
||||||
expiry: Duration;
|
|
||||||
use_expiry_from_token: boolean;
|
|
||||||
scope: string[];
|
|
||||||
extra_params: Record<string, string>;
|
|
||||||
|
|
||||||
allowed_domains: string[];
|
|
||||||
allowed_groups: string[];
|
|
||||||
allowed_users: string[];
|
|
||||||
|
|
||||||
strip_email_domain: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
logtail: {
|
|
||||||
enabled: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
randomize_client_port: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
let config: Document
|
|
||||||
|
|
||||||
export async function getConfig(force = false) {
|
|
||||||
if (!config || force) {
|
|
||||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
|
||||||
const data = await readFile(path, 'utf8')
|
|
||||||
config = parseDocument(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
return config.toJSON() as Config
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getAcl() {
|
|
||||||
let path = process.env.ACL_FILE
|
|
||||||
if (!path) {
|
|
||||||
try {
|
|
||||||
const config = await getConfig()
|
|
||||||
path = config.acl_policy_path
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
return { data: '', type: 'json' }
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await readFile(path, 'utf8')
|
|
||||||
|
|
||||||
// Naive check for YAML over JSON
|
|
||||||
// This is because JSON.parse doesn't support comments
|
|
||||||
try {
|
|
||||||
parse(data)
|
|
||||||
return { data, type: 'yaml' }
|
|
||||||
} catch {
|
|
||||||
return { data, type: 'json' }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// This is so obscenely dangerous, please have a check around it
|
|
||||||
export async function patchConfig(partial: Record<string, unknown>) {
|
|
||||||
for (const [key, value] of Object.entries(partial)) {
|
|
||||||
config.setIn(key.split('.'), value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
|
||||||
await writeFile(path, config.toString(), 'utf8')
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function patchAcl(data: string) {
|
|
||||||
let path = process.env.ACL_FILE
|
|
||||||
if (!path) {
|
|
||||||
try {
|
|
||||||
const config = await getConfig()
|
|
||||||
path = config.acl_policy_path
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
throw new Error('No ACL file defined')
|
|
||||||
}
|
|
||||||
|
|
||||||
await writeFile(path, data, 'utf8')
|
|
||||||
}
|
|
||||||
|
|
||||||
let watcher: FSWatcher
|
|
||||||
|
|
||||||
export function registerConfigWatcher() {
|
|
||||||
if (watcher) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
|
||||||
watcher = watch(path, async () => {
|
|
||||||
console.log('Config file changed, reloading')
|
|
||||||
await getConfig(true)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
type Context = {
|
|
||||||
hasDockerSock: boolean;
|
|
||||||
hasConfig: boolean;
|
|
||||||
hasConfigWrite: boolean;
|
|
||||||
hasAcl: boolean;
|
|
||||||
hasAclWrite: boolean;
|
|
||||||
headscaleUrl: string;
|
|
||||||
oidcConfig?: {
|
|
||||||
issuer: string;
|
|
||||||
client: string;
|
|
||||||
secret: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export let context: Context
|
|
||||||
|
|
||||||
export async function getContext() {
|
|
||||||
if (!context) {
|
|
||||||
context = {
|
|
||||||
hasDockerSock: await checkSock(),
|
|
||||||
hasConfig: await hasConfig(),
|
|
||||||
hasConfigWrite: await hasConfigW(),
|
|
||||||
hasAcl: await hasAcl(),
|
|
||||||
hasAclWrite: await hasAclW(),
|
|
||||||
headscaleUrl: await getHeadscaleUrl(),
|
|
||||||
oidcConfig: await getOidcConfig()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getOidcConfig() {
|
|
||||||
// Check for the OIDC environment variables first
|
|
||||||
let issuer = process.env.OIDC_ISSUER
|
|
||||||
let client = process.env.OIDC_CLIENT_ID
|
|
||||||
let secret = process.env.OIDC_CLIENT_SECRET
|
|
||||||
const rootKey = process.env.API_KEY
|
|
||||||
|
|
||||||
if (!issuer || !client || !secret) {
|
|
||||||
const config = await getConfig()
|
|
||||||
issuer = config.oidc?.issuer
|
|
||||||
client = config.oidc?.client_id
|
|
||||||
secret = config.oidc?.client_secret
|
|
||||||
}
|
|
||||||
|
|
||||||
// If atleast one is defined but not all 3, throw an error
|
|
||||||
if ((issuer || client || secret) && !(issuer && client && secret)) {
|
|
||||||
throw new Error('OIDC configuration is incomplete')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!issuer || !client || !secret) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rootKey) {
|
|
||||||
throw new Error('Cannot use OIDC without the root API_KEY variable set')
|
|
||||||
}
|
|
||||||
|
|
||||||
return { issuer, client, secret }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getHeadscaleUrl() {
|
|
||||||
if (process.env.HEADSCALE_URL) {
|
|
||||||
return process.env.HEADSCALE_URL
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = await getConfig()
|
|
||||||
if (config.server_url) {
|
|
||||||
return config.server_url
|
|
||||||
}
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
async function checkSock() {
|
|
||||||
try {
|
|
||||||
await access('/var/run/docker.sock', constants.R_OK)
|
|
||||||
return true
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (!process.env.HEADSCALE_CONTAINER) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hasConfig() {
|
|
||||||
try {
|
|
||||||
await getConfig()
|
|
||||||
return true
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hasConfigW() {
|
|
||||||
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
|
||||||
try {
|
|
||||||
await access(path, constants.W_OK)
|
|
||||||
return true
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hasAcl() {
|
|
||||||
let path = process.env.ACL_FILE
|
|
||||||
if (!path) {
|
|
||||||
try {
|
|
||||||
const config = await getConfig()
|
|
||||||
path = config.acl_policy_path
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
path = resolve(path)
|
|
||||||
await access(path, constants.R_OK)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Cannot acquire read access to ACL file', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
async function hasAclW() {
|
|
||||||
let path = process.env.ACL_FILE
|
|
||||||
if (!path) {
|
|
||||||
try {
|
|
||||||
const config = await getConfig()
|
|
||||||
path = config.acl_policy_path
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!path) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
path = resolve(path)
|
|
||||||
await access(path, constants.W_OK)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Cannot acquire read access to ACL file', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
// Handle the configuration loading for headplane.
|
||||||
|
// Functionally only used for all sorts of sanity checks across headplane.
|
||||||
|
//
|
||||||
|
// Around the codebase, this is referred to as the context
|
||||||
|
|
||||||
|
import { access, constants, readFile, writeFile } from 'node:fs/promises'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
import { parse } from 'yaml'
|
||||||
|
|
||||||
|
import { IntegrationFactory, loadIntegration } from '~/integration'
|
||||||
|
import { HeadscaleConfig, loadConfig } from '~/utils/config/headscale'
|
||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
|
export interface HeadplaneContext {
|
||||||
|
debug: boolean
|
||||||
|
headscaleUrl: string
|
||||||
|
headscalePublicUrl?: string
|
||||||
|
cookieSecret: string
|
||||||
|
integration: IntegrationFactory | undefined
|
||||||
|
|
||||||
|
config: {
|
||||||
|
read: boolean
|
||||||
|
write: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
oidc?: {
|
||||||
|
issuer: string
|
||||||
|
client: string
|
||||||
|
secret: string
|
||||||
|
rootKey: string
|
||||||
|
method: string
|
||||||
|
disableKeyLogin: boolean
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let context: HeadplaneContext | undefined
|
||||||
|
|
||||||
|
export async function loadContext(): Promise<HeadplaneContext> {
|
||||||
|
if (context) {
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
let headscalePublicUrl = process.env.HEADSCALE_PUBLIC_URL
|
||||||
|
|
||||||
|
if (!headscaleUrl && !config) {
|
||||||
|
throw new Error('HEADSCALE_URL not set')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config) {
|
||||||
|
headscaleUrl = headscaleUrl ?? config.server_url
|
||||||
|
if (!headscalePublicUrl) {
|
||||||
|
// Fallback to the config value if the env var is not set
|
||||||
|
headscalePublicUrl = config.public_url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!headscaleUrl) {
|
||||||
|
throw new Error('Missing server_url in headscale config')
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookieSecret = process.env.COOKIE_SECRET
|
||||||
|
if (!cookieSecret) {
|
||||||
|
throw new Error('COOKIE_SECRET not set')
|
||||||
|
}
|
||||||
|
|
||||||
|
context = {
|
||||||
|
debug,
|
||||||
|
headscaleUrl,
|
||||||
|
headscalePublicUrl,
|
||||||
|
cookieSecret,
|
||||||
|
integration: await loadIntegration(),
|
||||||
|
config: contextData,
|
||||||
|
oidc: await checkOidc(config),
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('CTXT', 'Starting Headplane with Context')
|
||||||
|
log.info('CTXT', 'HEADSCALE_URL: %s', headscaleUrl)
|
||||||
|
if (headscalePublicUrl) {
|
||||||
|
log.info('CTXT', 'HEADSCALE_PUBLIC_URL: %s', headscalePublicUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('CTXT', 'Integration: %s', context.integration?.name ?? 'None')
|
||||||
|
log.info('CTXT', 'Config: %s', contextData.read
|
||||||
|
? `Found ${contextData.write ? '' : '(Read Only)'}`
|
||||||
|
: 'Unavailable',
|
||||||
|
)
|
||||||
|
|
||||||
|
log.info('CTXT', 'OIDC: %s', context.oidc ? 'Configured' : 'Unavailable')
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkConfig(path: string) {
|
||||||
|
log.debug('CTXT', 'Checking config at %s', path)
|
||||||
|
|
||||||
|
let config: HeadscaleConfig | undefined
|
||||||
|
try {
|
||||||
|
config = await loadConfig(path)
|
||||||
|
} catch {
|
||||||
|
log.debug('CTXT', 'Config at %s failed to load', path)
|
||||||
|
return {
|
||||||
|
config: undefined,
|
||||||
|
contextData: {
|
||||||
|
read: false,
|
||||||
|
write: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let write = false
|
||||||
|
try {
|
||||||
|
log.debug('CTXT', 'Checking write access to %s', path)
|
||||||
|
await access(path, constants.W_OK)
|
||||||
|
write = true
|
||||||
|
} catch {
|
||||||
|
log.debug('CTXT', 'No write access to %s', path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
contextData: {
|
||||||
|
read: true,
|
||||||
|
write,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkOidc(config?: HeadscaleConfig) {
|
||||||
|
log.debug('CTXT', 'Checking OIDC configuration')
|
||||||
|
|
||||||
|
const disableKeyLogin = process.env.DISABLE_API_KEY_LOGIN === 'true'
|
||||||
|
log.debug('CTXT', 'API Key Login Enabled: %s', !disableKeyLogin)
|
||||||
|
|
||||||
|
log.debug('CTXT', 'Checking ROOT_API_KEY and falling back to API_KEY')
|
||||||
|
const rootKey = process.env.ROOT_API_KEY ?? process.env.API_KEY
|
||||||
|
if (!rootKey) {
|
||||||
|
throw new Error('ROOT_API_KEY or API_KEY not set')
|
||||||
|
}
|
||||||
|
|
||||||
|
let issuer = process.env.OIDC_ISSUER
|
||||||
|
let client = process.env.OIDC_CLIENT_ID
|
||||||
|
let secret = process.env.OIDC_CLIENT_SECRET
|
||||||
|
let method = process.env.OIDC_CLIENT_SECRET_METHOD ?? 'client_secret_basic'
|
||||||
|
|
||||||
|
log.debug('CTXT', 'Checking OIDC environment variables')
|
||||||
|
log.debug('CTXT', 'Issuer: %s', issuer)
|
||||||
|
log.debug('CTXT', 'Client: %s', client)
|
||||||
|
|
||||||
|
if (
|
||||||
|
(issuer ?? client ?? secret)
|
||||||
|
&& !(issuer && client && secret)
|
||||||
|
&& !config
|
||||||
|
) {
|
||||||
|
throw new Error('OIDC environment variables are incomplete')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (issuer && client && secret) {
|
||||||
|
return {
|
||||||
|
issuer,
|
||||||
|
client,
|
||||||
|
secret,
|
||||||
|
method,
|
||||||
|
rootKey,
|
||||||
|
disableKeyLogin,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((!issuer || !client || !secret) && config) {
|
||||||
|
issuer = config.oidc?.issuer
|
||||||
|
client = config.oidc?.client_id
|
||||||
|
secret = config.oidc?.client_secret
|
||||||
|
|
||||||
|
if (!secret && config.oidc?.client_secret_path) {
|
||||||
|
log.debug('CTXT', 'Trying to read OIDC client secret from %s', config.oidc.client_secret_path)
|
||||||
|
try {
|
||||||
|
const data = await readFile(
|
||||||
|
config.oidc.client_secret_path,
|
||||||
|
'utf8',
|
||||||
|
)
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
secret = data.trim()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
log.error('CTXT', 'Failed to read OIDC client secret from %s', config.oidc.client_secret_path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
(issuer ?? client ?? secret)
|
||||||
|
&& !(issuer && client && secret)
|
||||||
|
) {
|
||||||
|
throw new Error('OIDC configuration is incomplete')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!issuer || !client || !secret) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
issuer,
|
||||||
|
client,
|
||||||
|
secret,
|
||||||
|
rootKey,
|
||||||
|
method,
|
||||||
|
disableKeyLogin,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
// Handle the configuration loading for headscale.
|
||||||
|
// Functionally only used for reading and writing the configuration file.
|
||||||
|
// Availability checks and other configuration checks are done in the headplane
|
||||||
|
// configuration file that's adjacent to this one.
|
||||||
|
//
|
||||||
|
// Around the codebase, this is referred to as the config
|
||||||
|
// Refer to this file on juanfont/headscale for the default values:
|
||||||
|
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/config.go
|
||||||
|
import { readFile, writeFile } from 'node:fs/promises'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
import { type Document, parseDocument } from 'yaml'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import log from '~/utils/log'
|
||||||
|
|
||||||
|
const goBool = z
|
||||||
|
.union([z.boolean(), z.literal('true'), z.literal('false')])
|
||||||
|
.transform((value) => {
|
||||||
|
if (typeof value === 'boolean') {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return value === 'true'
|
||||||
|
})
|
||||||
|
|
||||||
|
const goDuration = z.union([z.literal(0), z.string()])
|
||||||
|
|
||||||
|
const HeadscaleConfig = z.object({
|
||||||
|
tls_letsencrypt_cache_dir: z.string().default('/var/www/cache'),
|
||||||
|
tls_letsencrypt_challenge_type: z.enum(['HTTP-01', 'TLS-ALPN-01']).default('HTTP-01'),
|
||||||
|
|
||||||
|
tls_letsencrypt_hostname: z.string().optional(),
|
||||||
|
tls_letsencrypt_listen: z.string().optional(),
|
||||||
|
|
||||||
|
tls_cert_path: z.string().optional(),
|
||||||
|
tls_key_path: z.string().optional(),
|
||||||
|
|
||||||
|
server_url: z.string().regex(/^https?:\/\//),
|
||||||
|
listen_addr: z.string(),
|
||||||
|
metrics_listen_addr: z.string().optional(),
|
||||||
|
grpc_listen_addr: z.string().default(':50443'),
|
||||||
|
grpc_allow_insecure: goBool.default(false),
|
||||||
|
|
||||||
|
disable_check_updates: goBool.default(false),
|
||||||
|
ephemeral_node_inactivity_timeout: goDuration.default('120s'),
|
||||||
|
randomize_client_port: goBool.default(false),
|
||||||
|
|
||||||
|
acme_email: z.string().optional(),
|
||||||
|
acme_url: z.string().optional(),
|
||||||
|
|
||||||
|
unix_socket: z.string().default('/var/run/headscale/headscale.sock'),
|
||||||
|
unix_socket_permission: z.string().default('0o770'),
|
||||||
|
|
||||||
|
policy: z.object({
|
||||||
|
mode: z.enum(['file', 'database']).default('file'),
|
||||||
|
path: z.string().optional(),
|
||||||
|
}).optional(),
|
||||||
|
|
||||||
|
tuning: z.object({
|
||||||
|
batch_change_delay: goDuration.default('800ms'),
|
||||||
|
node_mapsession_buffered_chan_size: z.number().default(30),
|
||||||
|
}).optional(),
|
||||||
|
|
||||||
|
noise: z.object({
|
||||||
|
private_key_path: z.string(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
log: z.object({
|
||||||
|
level: z.string().default('info'),
|
||||||
|
format: z.enum(['text', 'json']).default('text'),
|
||||||
|
}).default({ level: 'info', format: 'text' }),
|
||||||
|
|
||||||
|
logtail: z.object({
|
||||||
|
enabled: goBool.default(false),
|
||||||
|
}).default({ enabled: false }),
|
||||||
|
|
||||||
|
cli: z.object({
|
||||||
|
address: z.string().optional(),
|
||||||
|
api_key: z.string().optional(),
|
||||||
|
timeout: goDuration.default('10s'),
|
||||||
|
insecure: goBool.default(false),
|
||||||
|
}).optional(),
|
||||||
|
|
||||||
|
prefixes: z.object({
|
||||||
|
allocation: z.enum(['sequential', 'random']).default('sequential'),
|
||||||
|
v4: z.string(),
|
||||||
|
v6: z.string(),
|
||||||
|
}),
|
||||||
|
|
||||||
|
dns: z.object({
|
||||||
|
magic_dns: goBool.default(true),
|
||||||
|
base_domain: z.string().default('headscale.net'),
|
||||||
|
nameservers: z.object({
|
||||||
|
global: z.array(z.string()).default([]),
|
||||||
|
split: z.record(z.array(z.string())).default({}),
|
||||||
|
}).default({ global: [], split: {} }),
|
||||||
|
search_domains: z.array(z.string()).default([]),
|
||||||
|
extra_records: z.array(z.object({
|
||||||
|
name: z.string(),
|
||||||
|
type: z.literal('A'),
|
||||||
|
value: z.string(),
|
||||||
|
})).default([]),
|
||||||
|
use_username_in_magic_dns: goBool.default(false),
|
||||||
|
}),
|
||||||
|
|
||||||
|
oidc: z.object({
|
||||||
|
only_start_if_oidc_is_available: goBool.default(false),
|
||||||
|
issuer: z.string().optional(),
|
||||||
|
client_id: z.string().optional(),
|
||||||
|
client_secret: z.string().optional(),
|
||||||
|
client_secret_path: z.string().optional(),
|
||||||
|
scope: z.array(z.string()).default(['openid', 'profile', 'email']),
|
||||||
|
extra_params: z.record(z.unknown()).default({}),
|
||||||
|
allowed_domains: z.array(z.string()).optional(),
|
||||||
|
allowed_users: z.array(z.string()).optional(),
|
||||||
|
allowed_groups: z.array(z.string()).optional(),
|
||||||
|
strip_email_domain: goBool.default(false),
|
||||||
|
expiry: goDuration.default('180d'),
|
||||||
|
use_expiry_from_token: goBool.default(false),
|
||||||
|
}).optional(),
|
||||||
|
|
||||||
|
database: z.union([
|
||||||
|
z.object({
|
||||||
|
type: z.literal('sqlite'),
|
||||||
|
debug: goBool.default(false),
|
||||||
|
sqlite: z.object({
|
||||||
|
path: z.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('sqlite3'),
|
||||||
|
debug: goBool.default(false),
|
||||||
|
sqlite: z.object({
|
||||||
|
path: z.string(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
z.object({
|
||||||
|
type: z.literal('postgres'),
|
||||||
|
debug: goBool.default(false),
|
||||||
|
postgres: z.object({
|
||||||
|
host: z.string(),
|
||||||
|
port: z.number(),
|
||||||
|
name: z.string(),
|
||||||
|
user: z.string(),
|
||||||
|
pass: z.string(),
|
||||||
|
ssl: goBool.default(true),
|
||||||
|
max_open_conns: z.number().default(10),
|
||||||
|
max_idle_conns: z.number().default(10),
|
||||||
|
conn_max_idle_time_secs: z.number().default(3600),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
|
||||||
|
derp: z.object({
|
||||||
|
server: z.object({
|
||||||
|
enabled: goBool.default(true),
|
||||||
|
region_id: z.number().optional(),
|
||||||
|
region_code: z.string().optional(),
|
||||||
|
region_name: z.string().optional(),
|
||||||
|
stun_listen_addr: z.string().optional(),
|
||||||
|
private_key_path: z.string().optional(),
|
||||||
|
|
||||||
|
ipv4: z.string().optional(),
|
||||||
|
ipv6: z.string().optional(),
|
||||||
|
automatically_add_embedded_derp_region: goBool.default(true),
|
||||||
|
}),
|
||||||
|
|
||||||
|
urls: z.array(z.string()).optional(),
|
||||||
|
paths: z.array(z.string()).optional(),
|
||||||
|
auto_update_enabled: goBool.default(true),
|
||||||
|
update_frequency: goDuration.default('24h'),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type HeadscaleConfig = z.infer<typeof HeadscaleConfig>
|
||||||
|
|
||||||
|
export let configYaml: Document | undefined
|
||||||
|
export let config: HeadscaleConfig | undefined
|
||||||
|
|
||||||
|
export async function loadConfig(path?: string) {
|
||||||
|
if (config) {
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path) {
|
||||||
|
throw new Error('Path is required to lazy load config')
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('CFGX', 'Loading Headscale configuration from %s', path)
|
||||||
|
const data = await readFile(path, 'utf8')
|
||||||
|
configYaml = parseDocument(data)
|
||||||
|
|
||||||
|
if (process.env.HEADSCALE_CONFIG_UNSTRICT === 'true') {
|
||||||
|
log.debug('CFGX', 'Loaded Headscale configuration in non-strict mode')
|
||||||
|
const loaded = configYaml.toJSON() as Record<string, unknown>
|
||||||
|
config = {
|
||||||
|
...loaded,
|
||||||
|
tls_letsencrypt_cache_dir: loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache',
|
||||||
|
tls_letsencrypt_challenge_type: loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01',
|
||||||
|
grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443',
|
||||||
|
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
|
||||||
|
randomize_client_port: loaded.randomize_client_port ?? false,
|
||||||
|
unix_socket: loaded.unix_socket ?? '/var/run/headscale/headscale.sock',
|
||||||
|
unix_socket_permission: loaded.unix_socket_permission ?? '0o770',
|
||||||
|
tuning: loaded.tuning ?? {
|
||||||
|
batch_change_delay: '800ms',
|
||||||
|
node_mapsession_buffered_chan_size: 30,
|
||||||
|
},
|
||||||
|
|
||||||
|
log: loaded.log ?? {
|
||||||
|
level: 'info',
|
||||||
|
format: 'text',
|
||||||
|
},
|
||||||
|
|
||||||
|
logtail: loaded.logtail ?? {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
cli: loaded.cli ?? {
|
||||||
|
timeout: '10s',
|
||||||
|
insecure: false,
|
||||||
|
},
|
||||||
|
|
||||||
|
prefixes: loaded.prefixes ?? {
|
||||||
|
allocation: 'sequential',
|
||||||
|
v4: '',
|
||||||
|
v6: '',
|
||||||
|
},
|
||||||
|
|
||||||
|
dns: loaded.dns ?? {
|
||||||
|
nameservers: {
|
||||||
|
global: [],
|
||||||
|
split: {},
|
||||||
|
},
|
||||||
|
search_domains: [],
|
||||||
|
extra_records: [],
|
||||||
|
magic_dns: false,
|
||||||
|
base_domain: 'headscale.net',
|
||||||
|
},
|
||||||
|
} as HeadscaleConfig
|
||||||
|
|
||||||
|
log.warn('CFGX', 'Loaded Headscale configuration in non-strict mode')
|
||||||
|
log.warn('CFGX', 'By using this mode you forfeit GitHub issue support')
|
||||||
|
log.warn('CFGX', 'This is very dangerous and comes with a few caveats:')
|
||||||
|
log.warn('CFGX', 'Headplane could very easily crash')
|
||||||
|
log.warn('CFGX', 'Headplane could break your Headscale installation')
|
||||||
|
log.warn('CFGX', 'The UI could throw random errors/show incorrect data')
|
||||||
|
log.warn('CFGX', '')
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
log.debug('CFGX', 'Attempting to parse Headscale configuration')
|
||||||
|
config = await HeadscaleConfig.parseAsync(configYaml.toJSON())
|
||||||
|
} catch (error) {
|
||||||
|
log.debug('CFGX', 'Failed to load Headscale configuration')
|
||||||
|
if (error instanceof z.ZodError) {
|
||||||
|
log.error('CFGX', 'Recieved invalid configuration file')
|
||||||
|
log.error('CFGX', 'The following schema issues were found:')
|
||||||
|
for (const issue of error.issues) {
|
||||||
|
const path = issue.path.map(String).join('.')
|
||||||
|
const message = issue.message
|
||||||
|
|
||||||
|
log.error('CFGX', ` '${path}': ${message}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error('CFGX', '')
|
||||||
|
log.error('CFGX', 'Resolve these issues and try again.')
|
||||||
|
log.error('CFGX', 'Headplane will operate without the config')
|
||||||
|
log.error('CFGX', '')
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
return config
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is so obscenely dangerous, please have a check around it
|
||||||
|
export async function patchConfig(partial: Record<string, unknown>) {
|
||||||
|
if (!configYaml || !config) {
|
||||||
|
throw new Error('Config not loaded')
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('CFGX', 'Patching Headscale configuration')
|
||||||
|
for (const [key, value] of Object.entries(partial)) {
|
||||||
|
log.debug('CFGX', 'Patching %s with %s', key, value)
|
||||||
|
// If the key is something like `test.bar."foo.bar"`, then we treat
|
||||||
|
// the foo.bar as a single key, and not as two keys, so that needs
|
||||||
|
// to be split correctly.
|
||||||
|
|
||||||
|
// Iterate through each character, and if we find a dot, we check if
|
||||||
|
// the next character is a quote, and if it is, we skip until the next
|
||||||
|
// quote, and then we skip the next character, which should be a dot.
|
||||||
|
// If it's not a quote, we split it.
|
||||||
|
const path = []
|
||||||
|
let temp = ''
|
||||||
|
let inQuote = false
|
||||||
|
|
||||||
|
for (const element of key) {
|
||||||
|
if (element === '"') {
|
||||||
|
inQuote = !inQuote
|
||||||
|
}
|
||||||
|
|
||||||
|
if (element === '.' && !inQuote) {
|
||||||
|
path.push(temp.replaceAll('"', ''))
|
||||||
|
temp = ''
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
temp += element
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push the remaining element
|
||||||
|
path.push(temp.replaceAll('"', ''))
|
||||||
|
if (value === null) {
|
||||||
|
configYaml.deleteIn(path)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
configYaml.setIn(path, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
config = process.env.HEADSCALE_CONFIG_UNSTRICT === 'true'
|
||||||
|
? configYaml.toJSON() as HeadscaleConfig
|
||||||
|
: (await HeadscaleConfig.parseAsync(configYaml.toJSON()))
|
||||||
|
|
||||||
|
const path = resolve(process.env.CONFIG_FILE ?? '/etc/headscale/config.yaml')
|
||||||
|
log.debug('CFGX', 'Writing patched configuration to %s', path)
|
||||||
|
await writeFile(path, configYaml.toString(), 'utf8')
|
||||||
|
}
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
|
|
||||||
/* eslint-disable no-await-in-loop */
|
|
||||||
/* eslint-disable no-constant-condition */
|
|
||||||
import { setTimeout } from 'node:timers/promises'
|
|
||||||
|
|
||||||
import { Client } from 'undici'
|
|
||||||
|
|
||||||
import { getContext } from './config'
|
|
||||||
import { HeadscaleError, pull } from './headscale'
|
|
||||||
|
|
||||||
export async function sighupHeadscale() {
|
|
||||||
const context = await getContext()
|
|
||||||
if (!context.hasDockerSock) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.HEADSCALE_CONTAINER) {
|
|
||||||
throw new Error('HEADSCALE_CONTAINER is not set')
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new Client('http://localhost', {
|
|
||||||
socketPath: '/var/run/docker.sock'
|
|
||||||
})
|
|
||||||
|
|
||||||
const container = process.env.HEADSCALE_CONTAINER
|
|
||||||
const response = await client.request({
|
|
||||||
method: 'POST',
|
|
||||||
path: `/v1.30/containers/${container}/kill?signal=SIGHUP`
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.statusCode || response.statusCode !== 204) {
|
|
||||||
throw new Error('Failed to send SIGHUP to Headscale')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function restartHeadscale() {
|
|
||||||
const context = await getContext()
|
|
||||||
if (!context.hasDockerSock) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.HEADSCALE_CONTAINER) {
|
|
||||||
throw new Error('HEADSCALE_CONTAINER is not set')
|
|
||||||
}
|
|
||||||
|
|
||||||
const client = new Client('http://localhost', {
|
|
||||||
socketPath: '/var/run/docker.sock'
|
|
||||||
})
|
|
||||||
|
|
||||||
const container = process.env.HEADSCALE_CONTAINER
|
|
||||||
const response = await client.request({
|
|
||||||
method: 'POST',
|
|
||||||
path: `/v1.30/containers/${container}/restart`
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.statusCode || response.statusCode !== 204) {
|
|
||||||
throw new Error('Failed to restart Headscale')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for Headscale to restart before continuing
|
|
||||||
let attempts = 0
|
|
||||||
while (true) {
|
|
||||||
try {
|
|
||||||
// Acceptable blank because API_KEY is not required
|
|
||||||
await pull('v1/apikey', process.env.API_KEY ?? '')
|
|
||||||
return
|
|
||||||
} catch (error) {
|
|
||||||
// This means the server is up but the API key is invalid
|
|
||||||
// This can happen if the user only uses API_KEY via cookies
|
|
||||||
if (error instanceof HeadscaleError && error.status === 401) {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (attempts > 10) {
|
|
||||||
throw new Error('Headscale did not restart in time')
|
|
||||||
}
|
|
||||||
|
|
||||||
attempts++
|
|
||||||
await setTimeout(1000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getContext } from './config'
|
import { loadContext } from './config/headplane'
|
||||||
|
import log from './log'
|
||||||
|
|
||||||
export class HeadscaleError extends Error {
|
export class HeadscaleError extends Error {
|
||||||
status: number
|
status: number
|
||||||
@@ -18,15 +19,22 @@ export class FatalError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function pull<T>(url: string, key: string) {
|
export async function pull<T>(url: string, key: string) {
|
||||||
const context = await getContext()
|
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
|
const prefix = context.headscaleUrl
|
||||||
|
|
||||||
|
log.debug('APIC', 'GET %s', `${prefix}/api/${url}`)
|
||||||
const response = await fetch(`${prefix}/api/${url}`, {
|
const response = await fetch(`${prefix}/api/${url}`, {
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${key}`
|
Authorization: `Bearer ${key}`,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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)
|
throw new HeadscaleError(await response.text(), response.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,17 +42,49 @@ export async function pull<T>(url: string, key: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function post<T>(url: string, key: string, body?: unknown) {
|
export async function post<T>(url: string, key: string, body?: unknown) {
|
||||||
const context = await getContext()
|
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
|
const prefix = context.headscaleUrl
|
||||||
|
|
||||||
|
log.debug('APIC', 'POST %s', `${prefix}/api/${url}`)
|
||||||
const response = await fetch(`${prefix}/api/${url}`, {
|
const response = await fetch(`${prefix}/api/${url}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${key}`
|
Authorization: `Bearer ${key}`,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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)
|
throw new HeadscaleError(await response.text(), response.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,16 +92,23 @@ export async function post<T>(url: string, key: string, body?: unknown) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function del<T>(url: string, key: string) {
|
export async function del<T>(url: string, key: string) {
|
||||||
const context = await getContext()
|
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
|
const prefix = context.headscaleUrl
|
||||||
|
|
||||||
|
log.debug('APIC', 'DELETE %s', `${prefix}/api/${url}`)
|
||||||
const response = await fetch(`${prefix}/api/${url}`, {
|
const response = await fetch(`${prefix}/api/${url}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${key}`
|
Authorization: `Bearer ${key}`,
|
||||||
}
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!response.ok) {
|
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)
|
throw new HeadscaleError(await response.text(), response.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,36 +1,42 @@
|
|||||||
import { redirect } from '@remix-run/node'
|
import { redirect } from '@remix-run/node'
|
||||||
import {
|
import {
|
||||||
authorizationCodeGrantRequest,
|
authorizationCodeGrantRequest,
|
||||||
calculatePKCECodeChallenge, type Client,
|
calculatePKCECodeChallenge,
|
||||||
|
Client,
|
||||||
discoveryRequest,
|
discoveryRequest,
|
||||||
generateRandomCodeVerifier,
|
generateRandomCodeVerifier,
|
||||||
generateRandomNonce,
|
generateRandomNonce,
|
||||||
generateRandomState,
|
generateRandomState,
|
||||||
getValidatedIdTokenClaims, isOAuth2Error,
|
getValidatedIdTokenClaims,
|
||||||
|
isOAuth2Error,
|
||||||
parseWwwAuthenticateChallenges,
|
parseWwwAuthenticateChallenges,
|
||||||
processAuthorizationCodeOpenIDResponse,
|
processAuthorizationCodeOpenIDResponse,
|
||||||
processDiscoveryResponse,
|
processDiscoveryResponse,
|
||||||
validateAuthResponse } from 'oauth4webapi'
|
validateAuthResponse,
|
||||||
|
} from 'oauth4webapi'
|
||||||
|
|
||||||
import { post } from '~/utils/headscale'
|
import { post } from '~/utils/headscale'
|
||||||
import { commitSession, getSession } from '~/utils/sessions'
|
import { commitSession, getSession } from '~/utils/sessions'
|
||||||
|
|
||||||
export async function startOidc(issuer: string, client: string, request: Request) {
|
import { HeadplaneContext } from './config/headplane'
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
|
||||||
|
type OidcConfig = NonNullable<HeadplaneContext['oidc']>
|
||||||
|
|
||||||
|
export async function startOidc(oidc: OidcConfig, req: Request) {
|
||||||
|
const session = await getSession(req.headers.get('Cookie'))
|
||||||
if (session.has('hsApiKey')) {
|
if (session.has('hsApiKey')) {
|
||||||
return redirect('/', {
|
return redirect('/', {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
'Set-Cookie': await commitSession(session),
|
||||||
'Set-Cookie': await commitSession(session)
|
},
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const issuerUrl = new URL(issuer)
|
const issuerUrl = new URL(oidc.issuer)
|
||||||
const oidcClient = {
|
const oidcClient = {
|
||||||
client_id: client,
|
client_id: oidc.client,
|
||||||
token_endpoint_auth_method: 'client_secret_basic'
|
token_endpoint_auth_method: oidc.method,
|
||||||
} satisfies Client
|
} satisfies Client
|
||||||
|
|
||||||
const response = await discoveryRequest(issuerUrl)
|
const response = await discoveryRequest(issuerUrl)
|
||||||
@@ -44,9 +50,9 @@ export async function startOidc(issuer: string, client: string, request: Request
|
|||||||
const verifier = generateRandomCodeVerifier()
|
const verifier = generateRandomCodeVerifier()
|
||||||
const challenge = await calculatePKCECodeChallenge(verifier)
|
const challenge = await calculatePKCECodeChallenge(verifier)
|
||||||
|
|
||||||
const callback = new URL('/admin/oidc/callback', request.url)
|
const callback = new URL('/admin/oidc/callback', req.url)
|
||||||
callback.protocol = request.url.includes('localhost') ? 'http:' : 'https:'
|
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
|
||||||
callback.hostname = request.headers.get('Host') ?? ''
|
callback.host = req.headers.get('Host') ?? ''
|
||||||
const authUrl = new URL(processed.authorization_endpoint)
|
const authUrl = new URL(processed.authorization_endpoint)
|
||||||
|
|
||||||
authUrl.searchParams.set('client_id', oidcClient.client_id)
|
authUrl.searchParams.set('client_id', oidcClient.client_id)
|
||||||
@@ -65,29 +71,27 @@ export async function startOidc(issuer: string, client: string, request: Request
|
|||||||
return redirect(authUrl.href, {
|
return redirect(authUrl.href, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
'Set-Cookie': await commitSession(session),
|
||||||
'Set-Cookie': await commitSession(session)
|
},
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function finishOidc(issuer: string, client: string, secret: string, request: Request) {
|
export async function finishOidc(oidc: OidcConfig, req: Request) {
|
||||||
const session = await getSession(request.headers.get('Cookie'))
|
const session = await getSession(req.headers.get('Cookie'))
|
||||||
if (session.has('hsApiKey')) {
|
if (session.has('hsApiKey')) {
|
||||||
return redirect('/', {
|
return redirect('/', {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
'Set-Cookie': await commitSession(session),
|
||||||
'Set-Cookie': await commitSession(session)
|
},
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const issuerUrl = new URL(issuer)
|
const issuerUrl = new URL(oidc.issuer)
|
||||||
const oidcClient = {
|
const oidcClient = {
|
||||||
client_id: client,
|
client_id: oidc.client,
|
||||||
client_secret: secret,
|
client_secret: oidc.secret,
|
||||||
token_endpoint_auth_method: 'client_secret_basic'
|
token_endpoint_auth_method: oidc.method,
|
||||||
} satisfies Client
|
} satisfies Client
|
||||||
|
|
||||||
const response = await discoveryRequest(issuerUrl)
|
const response = await discoveryRequest(issuerUrl)
|
||||||
@@ -103,22 +107,41 @@ export async function finishOidc(issuer: string, client: string, secret: string,
|
|||||||
throw new Error('No OIDC state found in the session')
|
throw new Error('No OIDC state found in the session')
|
||||||
}
|
}
|
||||||
|
|
||||||
const parameters = validateAuthResponse(processed, oidcClient, new URL(request.url), state)
|
const parameters = validateAuthResponse(
|
||||||
|
processed,
|
||||||
|
oidcClient,
|
||||||
|
new URL(req.url),
|
||||||
|
state,
|
||||||
|
)
|
||||||
|
|
||||||
if (isOAuth2Error(parameters)) {
|
if (isOAuth2Error(parameters)) {
|
||||||
throw new Error('Invalid response from the OIDC provider')
|
throw new Error('Invalid response from the OIDC provider')
|
||||||
}
|
}
|
||||||
|
|
||||||
const callback = new URL('/admin/oidc/callback', request.url)
|
const callback = new URL('/admin/oidc/callback', req.url)
|
||||||
callback.protocol = request.url.includes('localhost') ? 'http:' : 'https:'
|
callback.protocol = req.headers.get('X-Forwarded-Proto') ?? 'http:'
|
||||||
callback.hostname = request.headers.get('Host') ?? ''
|
callback.host = req.headers.get('Host') ?? ''
|
||||||
|
|
||||||
|
const tokenResponse = await authorizationCodeGrantRequest(
|
||||||
|
processed,
|
||||||
|
oidcClient,
|
||||||
|
parameters,
|
||||||
|
callback.href,
|
||||||
|
verifier,
|
||||||
|
)
|
||||||
|
|
||||||
const tokenResponse = await authorizationCodeGrantRequest(processed, oidcClient, parameters, callback.href, verifier)
|
|
||||||
const challenges = parseWwwAuthenticateChallenges(tokenResponse)
|
const challenges = parseWwwAuthenticateChallenges(tokenResponse)
|
||||||
if (challenges) {
|
if (challenges) {
|
||||||
throw new Error('Recieved a challenge from the OIDC provider')
|
throw new Error('Recieved a challenge from the OIDC provider')
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await processAuthorizationCodeOpenIDResponse(processed, oidcClient, tokenResponse, nonce)
|
const result = await processAuthorizationCodeOpenIDResponse(
|
||||||
|
processed,
|
||||||
|
oidcClient,
|
||||||
|
tokenResponse,
|
||||||
|
nonce,
|
||||||
|
)
|
||||||
|
|
||||||
if (isOAuth2Error(result)) {
|
if (isOAuth2Error(result)) {
|
||||||
throw new Error('Invalid response from the OIDC provider')
|
throw new Error('Invalid response from the OIDC provider')
|
||||||
}
|
}
|
||||||
@@ -126,21 +149,23 @@ export async function finishOidc(issuer: string, client: string, secret: string,
|
|||||||
const claims = getValidatedIdTokenClaims(result)
|
const claims = getValidatedIdTokenClaims(result)
|
||||||
const expDate = new Date(claims.exp * 1000).toISOString()
|
const expDate = new Date(claims.exp * 1000).toISOString()
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
const keyResponse = await post<{ apiKey: string }>(
|
||||||
const keyResponse = await post<{ apiKey: string }>('v1/apikey', process.env.API_KEY!, {
|
'v1/apikey',
|
||||||
expiration: expDate
|
oidc.rootKey,
|
||||||
})
|
{
|
||||||
|
expiration: expDate,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
session.set('hsApiKey', keyResponse.apiKey)
|
session.set('hsApiKey', keyResponse.apiKey)
|
||||||
session.set('user', {
|
session.set('user', {
|
||||||
name: claims.name ? String(claims.name) : 'Anonymous',
|
name: claims.name ? String(claims.name) : 'Anonymous',
|
||||||
email: claims.email ? String(claims.email) : undefined
|
email: claims.email ? String(claims.email) : undefined,
|
||||||
})
|
})
|
||||||
|
|
||||||
return redirect('/machines', {
|
return redirect('/machines', {
|
||||||
headers: {
|
headers: {
|
||||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
'Set-Cookie': await commitSession(session),
|
||||||
'Set-Cookie': await commitSession(session)
|
},
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createCookieSessionStorage } from '@remix-run/node' // Or cloudflare/deno
|
import { createCookieSessionStorage } from '@remix-run/node' // Or cloudflare/deno
|
||||||
|
|
||||||
type SessionData = {
|
export type SessionData = {
|
||||||
hsApiKey: string;
|
hsApiKey: string;
|
||||||
authState: string;
|
authState: string;
|
||||||
authNonce: string;
|
authNonce: string;
|
||||||
@@ -27,9 +27,8 @@ export const {
|
|||||||
maxAge: 60 * 60 * 24, // 24 hours
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
path: '/',
|
path: '/',
|
||||||
sameSite: 'lax',
|
sameSite: 'lax',
|
||||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
||||||
secrets: [process.env.COOKIE_SECRET!],
|
secrets: [process.env.COOKIE_SECRET!],
|
||||||
secure: true
|
secure: process.env.COOKIE_SECURE !== 'false',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { useRevalidator } from '@remix-run/react'
|
|||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
import { useInterval } from 'usehooks-ts'
|
import { useInterval } from 'usehooks-ts'
|
||||||
|
|
||||||
type Properties = {
|
interface Props {
|
||||||
interval: number;
|
interval: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLiveData({ interval }: Properties) {
|
export function useLiveData({ interval }: Props) {
|
||||||
const revalidator = useRevalidator()
|
const revalidator = useRevalidator()
|
||||||
|
|
||||||
// Handle normal stale-while-revalidate behavior
|
// Handle normal stale-while-revalidate behavior
|
||||||
@@ -31,4 +31,5 @@ export function useLiveData({ interval }: Properties) {
|
|||||||
document.removeEventListener('focus', handler)
|
document.removeEventListener('focus', handler)
|
||||||
}
|
}
|
||||||
}, [revalidator])
|
}, [revalidator])
|
||||||
|
return revalidator
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 212 KiB |
|
After Width: | Height: | Size: 197 KiB |
|
After Width: | Height: | Size: 206 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 189 KiB |
|
Before Width: | Height: | Size: 142 KiB |
|
After Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 151 KiB |