Compare commits

..

3 Commits

Author SHA1 Message Date
Aarnav Tale 5171120df8 Revert "Implement path-based secret loading"
This reverts commit 8ea8c7195f.
2025-05-23 10:42:25 -04:00
Aarnav Tale 71bdca6b8b Revert "Rework the documentation to reflect the whitelist approach"
This reverts commit 91fcf5233e.
2025-05-23 10:42:25 -04:00
Aarnav Tale 13a711f505 Revert "Cleanup"
This reverts commit 62863ad649.
2025-05-23 10:42:25 -04:00
304 changed files with 11367 additions and 24568 deletions
+5 -9
View File
@@ -1,11 +1,7 @@
/.cache
/.github
/.react-router
/node_modules
/build
/docs
/nix
node_modules
.dockerignore
.env
Dockerfile
/.cache
/build
.env
+1
View File
@@ -0,0 +1 @@
use_flake
-3
View File
@@ -1,3 +0,0 @@
* @tale
/nix @tale @StealthBadger747
+26 -26
View File
@@ -1,31 +1,31 @@
name: Bug Report
description: Report an issue with Headplane
assignees: [tale]
labels: ["Bug", "Needs Triage"]
labels: [bug, triage]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the issue and steps to reproduce it.
If applicable, include any error messages or screenshots.
- type: textarea
attributes:
label: Description
description: |
A detailed description of the issue and steps to reproduce it.
If applicable, include any error messages or screenshots.
If this is not an issue with Headplane, but an issue with your
environment, please consider opening a discussion instead.
placeholder: e.g. "When I try to upload a file, I get an error message."
validations:
required: true
- type: input
attributes:
label: Headplane Version
description: What version of Headplane are you using?
placeholder: e.g. "v0.6.2"
validations:
required: true
- type: input
attributes:
label: Headscale Version
description: What version of Headscale are you using?
placeholder: e.g. "v0.27.1"
validations:
required: true
If this is not an issue with Headplane, but an issue with your
environment, please consider opening a discussion instead.
placeholder: e.g. "When I try to upload a file, I get an error message."
validations:
required: true
- type: input
attributes:
label: Headplane Version
description: What version of Headplane are you using?
placeholder: e.g. "v0.5.5"
validations:
required: true
- type: input
attributes:
label: Headscale Version
description: What version of Headscale are you using?
placeholder: e.g. "v0.25.1"
validations:
required: true
+11 -11
View File
@@ -1,15 +1,15 @@
name: Feature Request
description: Request a new feature or enhancement for Headplane
assignees: [tale]
labels: ["Feature", "Needs Triage"]
labels: [enhancement, triage]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the feature you would like to see added.
Please include any relevant context, such as why this feature is
important and how it would benefit other users beyond yourself.
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
validations:
required: true
- type: textarea
attributes:
label: Description
description: |
A detailed description of the feature you would like to see added.
Please include any relevant context, such as why this feature is
important and how it would benefit other users beyond yourself.
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
validations:
required: true
+26 -20
View File
@@ -2,12 +2,14 @@ name: Build
on:
push:
paths-ignore:
- ".zed/**"
- "assets/**"
- "docs/**"
- "CHANGELOG.md"
- "README.md"
branches:
- "main"
- "next"
pull_request:
concurrency:
@@ -19,37 +21,41 @@ permissions:
contents: read # Read access to the repository
jobs:
ci:
name: Build and Test
native:
name: native
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Install node.js
uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
- 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:
node-version-file: package.json
cache: pnpm
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Install dependencies
run: pnpm install
- name: CI pipeline
run: ./build.sh --skip-pnpm-prune
- name: Run unit tests
run: pnpm run test:unit
- name: Run integration tests
run: pnpm run test:integration
- name: Build
run: pnpm build
nix:
name: nix
+3 -30
View File
@@ -12,8 +12,6 @@ permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
id-token: write # For the attest action to push
attestations: write # For the attest action to push
jobs:
publish:
@@ -21,14 +19,6 @@ jobs:
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
name: Docker Pre-release
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: final
tag: "next"
- target: debug-shell
tag: "next-shell"
steps:
- name: Check out the repo
uses: actions/checkout@v4
@@ -39,7 +29,7 @@ jobs:
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=${{ matrix.tag }}
type=raw,value=next
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -54,29 +44,12 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish ghcr.io/${{ github.repository }}:${{ matrix.tag }}
uses: docker/build-push-action@v6
id: push
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
target: ${{ matrix.target }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ matrix.tag }}
HEADPLANE_VERSION=next+${{ github.sha }}
secrets: |
gh_token=${{ secrets.GITHUB_TOKEN }}
- name: Attestation Provenance for ghcr.io/${{ github.repository }}:${{ matrix.tag }}
uses: actions/attest-build-provenance@v2
id: attest
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
+2 -29
View File
@@ -12,21 +12,11 @@ permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
id-token: write # For the attest action to push
attestations: write # For the attest action to push
jobs:
docker:
name: Docker Release
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: final
tag_suffix: ""
- target: debug-shell
tag_suffix: "-shell"
steps:
- name: Check out the repo
uses: actions/checkout@v4
@@ -38,9 +28,6 @@ jobs:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
flavor: |
latest=auto
suffix=${{ matrix.tag_suffix }},onlatest=true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -55,26 +42,12 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
uses: docker/build-push-action@v6
id: push
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
target: ${{ matrix.target }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
HEADPLANE_VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
- name: Attestation Provenance for ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
uses: actions/attest-build-provenance@v2
id: attest
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
-9
View File
@@ -4,12 +4,3 @@ node_modules
/build
/test
.env
/app/hp_ssh.wasm
/app/wasm_exec.js
/public/hp_ssh.wasm
/public/wasm_exec.js
/docs/.vitepress/dist/
/docs/.vitepress/cache/
/.direnv
+1
View File
@@ -1 +1,2 @@
side-effects-cache = false
public-hoist-pattern[]=*hono*
-10
View File
@@ -1,10 +0,0 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"endOfLine": "lf",
"experimentalSortImports": {},
"experimentalTailwindcss": {},
"ignorePatterns": [],
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}
-144
View File
@@ -1,144 +0,0 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["unicorn", "typescript", "oxc"],
"categories": {},
"rules": {
"constructor-super": "warn",
"for-direction": "warn",
"no-async-promise-executor": "warn",
"no-caller": "warn",
"no-class-assign": "warn",
"no-compare-neg-zero": "warn",
"no-cond-assign": "warn",
"no-const-assign": "warn",
"no-constant-binary-expression": "warn",
"no-constant-condition": "warn",
"no-control-regex": "warn",
"no-debugger": "warn",
"no-delete-var": "warn",
"no-dupe-class-members": "warn",
"no-dupe-else-if": "warn",
"no-dupe-keys": "warn",
"no-duplicate-case": "warn",
"no-empty-character-class": "warn",
"no-empty-pattern": "warn",
"no-empty-static-block": "warn",
"no-eval": "warn",
"no-ex-assign": "warn",
"no-extra-boolean-cast": "warn",
"no-func-assign": "warn",
"no-global-assign": "warn",
"no-import-assign": "warn",
"no-invalid-regexp": "warn",
"no-irregular-whitespace": "warn",
"no-loss-of-precision": "warn",
"no-new-native-nonconstructor": "warn",
"no-nonoctal-decimal-escape": "warn",
"no-obj-calls": "warn",
"no-self-assign": "warn",
"no-setter-return": "warn",
"no-shadow-restricted-names": "warn",
"no-sparse-arrays": "warn",
"no-this-before-super": "warn",
"no-unassigned-vars": "warn",
"no-unsafe-finally": "warn",
"no-unsafe-negation": "warn",
"no-unsafe-optional-chaining": "warn",
"no-unused-expressions": "warn",
"no-unused-labels": "warn",
"no-unused-private-class-members": "warn",
"no-unused-vars": "warn",
"no-useless-backreference": "warn",
"no-useless-catch": "warn",
"no-useless-escape": "warn",
"no-useless-rename": "warn",
"no-with": "warn",
"require-yield": "warn",
"use-isnan": "warn",
"valid-typeof": "warn",
"oxc/bad-array-method-on-arguments": "warn",
"oxc/bad-char-at-comparison": "warn",
"oxc/bad-comparison-sequence": "warn",
"oxc/bad-min-max-func": "warn",
"oxc/bad-object-literal-comparison": "warn",
"oxc/bad-replace-all-arg": "warn",
"oxc/const-comparisons": "warn",
"oxc/double-comparisons": "warn",
"oxc/erasing-op": "warn",
"oxc/missing-throw": "warn",
"oxc/number-arg-out-of-range": "warn",
"oxc/only-used-in-recursion": "warn",
"oxc/uninvoked-array-callback": "warn",
"typescript/await-thenable": "warn",
"typescript/no-array-delete": "warn",
"typescript/no-base-to-string": "warn",
"typescript/no-duplicate-enum-values": "warn",
"typescript/no-duplicate-type-constituents": "warn",
"typescript/no-extra-non-null-assertion": "warn",
"typescript/no-floating-promises": "warn",
"typescript/no-for-in-array": "warn",
"typescript/no-implied-eval": "warn",
"typescript/no-meaningless-void-operator": "warn",
"typescript/no-misused-new": "warn",
"typescript/no-misused-spread": "warn",
"typescript/no-non-null-asserted-optional-chain": "warn",
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-this-alias": "warn",
"typescript/no-unnecessary-parameter-property-assignment": "warn",
"typescript/no-unsafe-declaration-merging": "warn",
"typescript/no-unsafe-unary-minus": "warn",
"typescript/no-useless-empty-export": "warn",
"typescript/no-wrapper-object-types": "warn",
"typescript/prefer-as-const": "warn",
"typescript/require-array-sort-compare": "warn",
"typescript/restrict-template-expressions": "warn",
"typescript/triple-slash-reference": "warn",
"typescript/unbound-method": "warn",
"unicorn/no-await-in-promise-methods": "warn",
"unicorn/no-empty-file": "warn",
"unicorn/no-invalid-fetch-options": "warn",
"unicorn/no-invalid-remove-event-listener": "warn",
"unicorn/no-new-array": "warn",
"unicorn/no-single-promise-in-promise-methods": "warn",
"unicorn/no-thenable": "warn",
"unicorn/no-unnecessary-await": "warn",
"unicorn/no-useless-fallback-in-spread": "warn",
"unicorn/no-useless-length-check": "warn",
"unicorn/no-useless-spread": "warn",
"unicorn/prefer-set-size": "warn",
"unicorn/prefer-string-starts-ends-with": "warn"
},
"settings": {
"jsx-a11y": {
"polymorphicPropName": null,
"components": {},
"attributes": {}
},
"next": {
"rootDir": []
},
"react": {
"formComponents": [],
"linkComponents": [],
"version": null
},
"jsdoc": {
"ignorePrivate": false,
"ignoreInternal": false,
"ignoreReplacesDocs": true,
"overrideReplacesDocs": true,
"augmentsExtendsReplacesDocs": false,
"implementsReplacesDocs": false,
"exemptDestructuredRootsFromChecks": false,
"tagNamePreference": {}
},
"vitest": {
"typecheck": false
}
},
"env": {
"builtin": true
},
"globals": {},
"ignorePatterns": []
}
+2
View File
@@ -0,0 +1,2 @@
pnpm 10.4.0
node 22
+17
View File
@@ -0,0 +1,17 @@
{
"formatter": {
"language_server": {
"name": "biome"
}
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
},
"languages": {
"YAML": {
"tab_size": 2,
"hard_tabs": false
}
}
}
+39 -249
View File
@@ -1,125 +1,12 @@
# 0.6.2 (February 26, 2026)
- **Added support for Headscale 0.28.0** including all API and data model changes.
- Added search and sortable columns to the machines list page (closes [#351](https://github.com/tale/headplane/issues/351)).
- Added support for Headscale 0.27.0 and 0.27.1
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
- Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)).
- Implemented the ability to customize the build with a custom script (see `./build.sh --help` for more information).
- Attempt to warn against misconfigured cookie settings on the login page.
- Made `server.cookie_max_age` and `server.cookie_domain` configurable (closes [#348](https://github.com/tale/headplane/issues/348)).
- Re-worked the configuration loading system with several enhancements:
- It is now possible to skip a configuration file and only use environment variables (closes [#150](https://github.com/tale/headplane/issues/150)).
- Secret path loading has been reworked from the ground up to be more reliable (closes [#334](https://github.com/tale/headplane/issues/334)).
- Added better testing and validation for configuration loading
- Re-worked the OIDC integration to adhere to the correct standards and surface more errors to the user.
- Deprecated `oidc.redirect_uri` and automated callback URL detection in favor of setting `server.base_url` correctly.
- Explicitly added `oidc.use_pkce` to correctly determine PKCE configuration.
- `oidc.token_endpoint_auth_method` is now optional and will attempt to be auto-detected, defaulting to `client_secret_basic` if unavailable (closes [#410](https://github.com/tale/headplane/issues/410)).
- Added `oidc.enabled` config option to explicitly control OIDC availability (via [#463](https://github.com/tale/headplane/pull/463)).
- Removed several unnecessarily verbose or spammy log messages.
- Updated the minimum Docker API used to support the latest Docker versions (via [#370](https://github.com/tale/headplane/pull/370)).
- Enhanced the node tag dialog to show a dropdown of assignable tags (via [#362](https://github.com/tale/headplane/pull/362)).
- Fixed an issue where the website favicon would not load correctly (closes [#323](https://github.com/tale/headplane/issues/323)).
- Correctly handle invalid ACL policy inserts on Headscale 0.27+ (closes [#383](https://github.com/tale/headplane/issues/383)).
- Prevent a machine from changing its owner to itself (closes [#373](https://github.com/tale/headplane/issues/373)).
- Added an `/admin/api/info` route that can expose sensitive information if `server.info_secret` is set in the configuration (closes [#324](https://github.com/tale/headplane/issues/324)).
- Correctly apply Gravatar profile pictures on the user page if applicable (closes [#405](https://github.com/tale/headplane/issues/405)).
- Machine key registration no longer works if the key isn't 24 characters long (closes [#415](https://github.com/tale/headplane/issues/415)).
- Fixed some mobile CSS issues across the application (closes [#401](https://github.com/tale/headplane/issues/401)).
- Added a Docker healthcheck to the container (closes [#411](https://github.com/tale/headplane/issues/411)).
- Strengthened the validation for the `/proc` integration to correctly discover the Headscale PID.
- Added lazy retry logic for OIDC providers if they initially fail to respond (closes [#423](https://github.com/tale/headplane/issues/423)).
- Fixed API key login on Headscale 0.28.0-beta.1+ (closes [#429](https://github.com/tale/headplane/issues/429)).
- Fixed an issue that prevented the pre-auth-key UI from being usable on Headscale 0.28 and later.
- Added support for creating tag-only pre-auth keys on Headscale 0.28+ (via [#465](https://github.com/tale/headplane/pull/465)).
- Pre-auth keys are now listed without a user filter on Headscale 0.28+, with a fallback to per-user fetching on older versions (via [#466](https://github.com/tale/headplane/pull/466)).
- Fixed handling of tag-only nodes that have no user on Headscale 0.28+ (via [#467](https://github.com/tale/headplane/pull/467)).
- Adapted to the removal of Node Ownership Change in Headscale 0.28 (via [#436](https://github.com/tale/headplane/pull/436)).
- Fixed pre-auth keys not showing for OIDC users without a username (via [#470](https://github.com/tale/headplane/pull/470)).
- Fixed truncated pre-auth key display with longer Headscale 0.28 bcrypt tokens (closes [#435](https://github.com/tale/headplane/issues/435)).
- Fixed Nix systemd service to use user-specified package (via [#454](https://github.com/tale/headplane/pull/454)).
- Version displayed in the UI is now derived from git tags and build args instead of `package.json`, fixing incorrect versions shown on beta and nightly builds.
- Improved the no-access user page on the UI (via [#469](https://github.com/tale/headplane/pull/469)).
---
# 0.6.1 (October 12, 2025)
- **Headplane now supports connecting to machines via SSH in the web browser.**
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
- Begin using a new SQLite database file in `/var/lib/headplane/hp_persist.db`.
- The database is created automatically if it does not exist.
- It currently stores SSH connection details and HostInfo for the agent.
- User information is automatically migrated from the previous database.
- The docker container now runs in a distroless image (closes [#255](https://github.com/tale/headplane/issues/255)).
- A debug version of the container that runs as root and has a shell is available as `ghcr.io/tale/headplane:<version>-shell`.
- Removing a Split DNS record will no longer make the split domain unresolvable by clients (closes [#231](https://github.com/tale/headplane/issues/231)).
- Reintroduce the toggle for overriding local DNS settings in the Headscale config (closes [#236](https://github.com/tale/headplane/issues/236)).
- Prefer cross-compiling in the Dockerfile to speed up builds while still supporting multiple architectures.
- Add a build attestation to validate SLSA provenance for the Docker image.
- Implement more accurate guessing on the PID with the `/proc` integration (via [#219](https://github.com/tale/headplane/pull/219)).
- Usernames will now correctly fall back to emails if not provided (via [#257](https://github.com/tale/headplane/pull/257)).
- Configuration loading via paths is now supported for sensitive values (via [#283](https://github.com/tale/headplane/pulls/283))
- Options like `server.cookie_secret_path` can override `server.cookie_secret`
- Environment variables are interpolatable into these paths
- See the full reference in the [docs](https://github.com/tale/headplane/blob/main/docs/Configuration.md#sensitive-values)
- The nix overlay build is fixed for the SSH module (via [#282](https://github.com/tale/headplane/pull/282))
- Switch our build processes to use TypeScript Go and Rolldown Vite for better build and type-check performance.
- Cookies are now encrypted JWTs, preserving API key secrets (_GHSA-wrqq-v7qw-r5w7_)
- OIDC profile pictures are now available from Gravatar by setting `oidc.profile_picture_source` to `gravatar` (closes [#232](https://github.com/tale/headplane/issues/232)).
- OIDC now allows passing many custom parameters:
- `oidc.authorization_endpoint`, `oidc.token_endpoint`, and `oidc.userinfo_endpoint` can be overridden to support non-standard providers or scenarios without discovery (closes [#117](https://github.com/tale/headplane/issues/117)).
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
---
# 0.6.0 (May 25, 2025)
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
- Breaking API changes with routes and pre auth keys are now supported (closes [#204](https://github.com/tale/headplane/issues/204)).
- Older versions of Headscale will not work with Headplane.
- OIDC authorization restrictions can now be controlled from the settings UI. (closes [#102](https://github.com/tale/headplane/issues/102))
- The required permission role for this is **IT Admin** or **Admin/Owner** and require the Headscale configuration.
- Changes made will modify the `oidc.allowed_{domains,groups,users}` fields in the Headscale config file.
- The Pre-Auth keys page has been fully reworked (closes [#179](https://github.com/tale/headplane/issues/179), [#143](https://github.com/tale/headplane/issues/143)).
- The Headplane agent is now available as an integration (closes [#65](https://github.com/tale/headplane/issues/65)).
- The agent runs as an embedded process alongside the Headplane server and reports host information and system metrics.
- Refer to the `integrations.agent` section of the config file for more information and how to enable it.
- Requests to `/admin` will now be redirected to `/admin/` to prevent issues with the React Router (works with custom prefixes, closes [#173](https://github.com/tale/headplane/issues/173)).
- The Login page has been simplified and separately reports errors versus incorrect API keys (closes [#186](https://github.com/tale/headplane/issues/186)).
- The machine actions backend has been reworked to better handle errors and provide more information to the user (closes [#185](https://github.com/tale/headplane/issues/185)).
- Machine tags now show states when waiting for subnet or exit node approval and when expiry is disabled.
- Expiry status on the UI was incorrectly showing as never due to changes in the Headscale API.
- Added validation for machine renaming to prevent invalid submissions (closes [#192](https://github.com/tale/headplane/issues/192)).
- Unmanaged (non-OIDC) users cannot have a role assigned to them so the menu option was disabled.
- Support Docker container discovery through labels (via [#194](https://github.com/tale/headplane/pull/194)).
- AAAA records are now supported on the DNS page (closes [#189](https://github.com/tale/headplane/issues/189)).
- Add support for `dns.extra_records_path` in the Headscale config (closes [#144](https://github.com/tale/headplane/issues/144)).
- Tighten `proc` integration logic by checking for the `headscale serve` command (via #[195](https://github.com/tale/headplane/pull/195)).
- Strip newlines in the OIDC `client_secret_path` file if provided (closes [#199](https://github.com/tale/headplane/issues/199)).
---
# 0.5.10 (April 4, 2025)
- Fix an issue where other preferences to skip onboarding affected every user.
---
# 0.5.9 (April 3, 2025)
### 0.5.10 (April 4, 2025)
- Fix an issue where other prefernences to skip onboarding affected every user.
### 0.5.9 (April 3, 2025)
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
- Suppress hydration errors for any fields with a date in it.
---
# 0.5.8 (April 3, 2025)
### 0.5.8 (April 3, 2025)
- You can now skip the onboarding page if desired.
- Added the UI to change user roles in the dashboard.
- Fixed an issue where integrations would throw instead of loading properly.
@@ -127,21 +14,15 @@
- Automatically create `/var/lib/headplane` in the Docker container (fixes [#166](https://github.com/tale/headplane/issues/166))
- OIDC logout with `disable_api_key_login` set to true will not automatically login again (fixes [#149](https://github.com/tale/headplane/issues/149))
---
# 0.5.7 (April 2, 2025)
### 0.5.7 (April 2, 2025)
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
---
# 0.5.6 (April 2, 2025)
### 0.5.6 (April 2, 2025)
### IMPORTANT
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously *ANY* accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously _ANY_ accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
Implemented _proper_ authentication methods for OIDC.
Implemented *proper* authentication methods for OIDC.
This is a large update and copies the permission system from Tailscale.
Permissions are not automatically derived from OIDC, but they can be configured via the UI.
Additionally, certain roles give certain capabilities, limiting access to parts of the dashboard.
@@ -149,7 +30,6 @@ By default, new users will have a `member` role which forbids access to the UI.
If there are no users, the first user will be given an `owner` role which cannot be removed.
**Changes**:
- Switched the internal server to use `hono` for better performance.
- Fixed an issue that caused dialogs to randomly refocus every 3 seconds.
- Headplane will not send API requests when the tab is not focused.
@@ -157,16 +37,10 @@ If there are no users, the first user will be given an `owner` role which cannot
- Unknown values in the Headplane config no longer cause a crash.
- Fixed an issue that caused copied commands to have a random space (fixes [#161](https://github.com/tale/headplane/issues/161))
---
# 0.5.5 (March 18, 2025)
### 0.5.5 (March 18, 2025)
- Hotfix an issue that caused Headplane to crash if no agents are available
---
# 0.5.4 (March 18, 2025)
### 0.5.4 (March 18, 2025)
- Fixed a typo in the Kubernetes documentation
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
@@ -180,32 +54,20 @@ If there are no users, the first user will be given an `owner` role which cannot
- You can now pass in an OIDC client secret through `oidc.client_secret_path` in the config (fixes [#126](https://github.com/tale/headplane/issues/126))
- Correctly handle differently localized number inputs (fixes [#125](https://github.com/tale/headplane/issues/125))
---
# 0.5.3 (March 1, 2025)
### 0.5.3 (March 1, 2025)
- Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111))
- Added an ARIA indicator for when an input is required and fixed the confirm buttons (fixed [#116](https://github.com/tale/headplane/issues/116))
- Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112))
---
# 0.5.2 (February 28, 2025)
### 0.5.2 (February 28, 2025)
- Hotfixed an issue where the server bundle got reloaded on each request
---
# 0.5.1 (February 28, 2025)
### 0.5.1 (February 28, 2025)
- Fixed an issue that caused the entire server to crash on start
- Fixed the published semver tags from Docker
- Fixed the Kubernetes integration not reading the config
---
# 0.5 (February 27, 2025)
### 0.5 (February 27, 2025)
> This release is a major overhaul and contains a significant breaking change.
> We now use a config file for all settings instead of environment variables.
> Please see [config.example.yaml](/config.example.yaml) for the new format.
@@ -220,16 +82,10 @@ If there are no users, the first user will be given an `owner` role which cannot
- Settings that were previously shared like `public_url` or `oidc` are now separate within Headplane/Headscale. This is a rather large breaking change but fixes cases where a user may choose to utilize Headscale OIDC for Tailscale but not for the Headplane UI.
- Deprecate the `latest` tag in Docker for explicit versioning and `edge` for nightly builds.
---
# 0.4.1 (January 18, 2025)
### 0.4.1 (January 18, 2025)
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
---
# 0.4.0 (January 18, 2025)
### 0.4.0 (January 18, 2025)
- Switched from Remix.run to React-Router
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
@@ -242,47 +98,29 @@ If there are no users, the first user will be given an `owner` role which cannot
- Reusable Pre-Auth Keys no longer show expired when used (PR [#88](https://github.com/tale/headplane/pull/88))
- Tweaked some CSS issues in the UI
---
# 0.3.9 (December 6, 2024)
### 0.3.9 (December 6, 2024)
- Fixed a race condition bug in the OIDC validation code
---
# 0.3.8 (December 6, 2024)
### 0.3.8 (December 6, 2024)
- Added a little HTML footer to show the login page and link to a donation page.
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
---
# 0.3.7 (November 30, 2024)
### 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)
### 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)
### 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)
### 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.
@@ -292,106 +130,70 @@ If there are no users, the first user will be given an `owner` role which cannot
- 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)
### 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)
### 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)
### 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)
### 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)
### 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)
### 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)
### 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)
### 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)
### 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)
### 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)
### 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)
### 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.
@@ -399,27 +201,19 @@ If there are no users, the first user will be given an `owner` role which cannot
- 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)
### 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)
### 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)
### 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.
@@ -428,17 +222,13 @@ If there are no users, the first user will be given an `owner` role which cannot
- 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)
### 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.
- Implemented an expiry check for machines which now reflect on the machine table.
-10
View File
@@ -1,10 +0,0 @@
https://localhost {
reverse_proxy headscale:8080
tls /certs/localhost.pem /certs/localhost-key.pem
header {
Access-Control-Allow-Origin *
Access-Control-Allow-Headers *
Access-Control-Allow-Methods GET, POST, OPTIONS
}
}
+11 -70
View File
@@ -1,77 +1,18 @@
FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
WORKDIR /run
FROM node:22-alpine AS build
WORKDIR /app
COPY go.mod go.sum build.sh ./
RUN go mod download
COPY cmd/ ./cmd/
COPY internal/ ./internal/
ARG TARGETOS
ARG TARGETARCH
ARG IMAGE_TAG
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 IMAGE_TAG=$IMAGE_TAG \
./build.sh --wasm --agent --fake-shell --healthcheck \
--wasm-output /bin/hp_ssh.wasm \
--agent-output /bin/hp_agent \
--fake-shell-output /bin/fake-sh \
--healthcheck-output /bin/hp_healthcheck
RUN chmod +x /bin/hp_ssh.wasm
RUN chmod +x /bin/hp_agent
RUN chmod +x /bin/fake-sh
RUN chmod +x /bin/hp_healthcheck
# Folder needs to exist for later stages
RUN mkdir -p /var/lib/headplane/agent
FROM --platform=$BUILDPLATFORM node:22.16-slim AS js-base
WORKDIR /run
RUN corepack enable
RUN npm install -g pnpm@10
RUN apk add --no-cache git
COPY package.json pnpm-lock.yaml ./
COPY patches ./patches
COPY package.json pnpm-lock.yaml build.sh ./
COPY --from=go-base /bin/hp_ssh.wasm /run/app/hp_ssh.wasm
COPY --from=go-base /bin/wasm_exec.js /run/app/wasm_exec.js
RUN ./build.sh --app --app-install-only
RUN pnpm install --frozen-lockfile
COPY . .
ARG HEADPLANE_VERSION
RUN HEADPLANE_VERSION=$HEADPLANE_VERSION ./build.sh --app
RUN pnpm run build
FROM gcr.io/distroless/nodejs22-debian12:latest AS final
COPY --from=js-base /run/build /app/build
COPY --from=js-base /run/drizzle /app/drizzle
COPY --from=js-base /run/node_modules /app/node_modules
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
COPY --from=go-base /var/lib/headplane /var/lib/headplane
# Fake shell to inform the user that they should use the debug image
COPY --from=go-base /bin/fake-sh /bin/sh
COPY --from=go-base /bin/fake-sh /bin/bash
COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/bin/hp_healthcheck"]
FROM node:22-alpine
RUN mkdir -p /var/lib/headplane
WORKDIR /app
CMD [ "/app/build/server/index.js" ]
FROM node:22-alpine AS debug-shell
RUN apk add --no-cache bash curl
COPY --from=js-base /run/build /app/build
COPY --from=js-base /run/drizzle /app/drizzle
COPY --from=js-base /run/node_modules /app/node_modules
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
COPY --from=go-base /var/lib/headplane /var/lib/headplane
COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/bin/hp_healthcheck"]
WORKDIR /app
CMD [ "node", "/app/build/server/index.js" ]
COPY --from=build /app/build /app/build
CMD [ "node", "./build/server/index.js" ]
+22 -12
View File
@@ -4,15 +4,15 @@
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./docs/assets/preview-dark.png"
srcset="./assets/preview-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./docs/assets/preview-light.png"
srcset="./assets/preview-light.png"
>
<img
alt="Preview"
src="./docs/assets/preview-dark.png"
src="./assets/preview-dark.png"
>
</picture>
@@ -32,14 +32,24 @@ These are some of the features that Headplane offers:
- Configurability for Headscale's settings
## Deployment
Refer to the [website](https://headplane.net) for detailed installation instructions.
Headplane runs as a server-based web-application, meaning you'll need a server to run it.
It's available as a Docker image (recommended) or through a manual installation.
There are 2 ways to deploy Headplane:
## Versioning
- ### [Integrated Mode (Recommended)](/docs/Integrated-Mode.md)
Integrated mode unlocks all the features of Headplane and is the most
feature-complete deployment method. It communicates with Headscale directly.
- ### [Simple Mode](/docs/Simple-Mode.md)
Simple mode does not include the automatic management of DNS and Headplane
settings, requiring manual editing and reloading when making changes.
### Versioning
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
Pre-release builds are available under the `next` tag and get updated when a new release
PR is opened and actively in testing.
## Contributing
### Contributing
Headplane is an open-source project and contributions are welcome! If you have
any suggestions, bug reports, or feature requests, please open an issue. Also
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
@@ -49,30 +59,30 @@ refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./docs/assets/acls-dark.png"
srcset="./assets/acls-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./docs/assets/acls-light.png"
srcset="./assets/acls-light.png"
>
<img
alt="ACLs"
src="./docs/assets/acls-dark.png"
src="./assets/acls-dark.png"
>
</picture>
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./docs/assets/machine-dark.png"
srcset="./assets/machine-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./docs/assets/machine-light.png"
srcset="./assets/machine-light.png"
>
<img
alt="Machine Management"
src="./docs/assets/machine-dark.png"
src="./assets/machine-dark.png"
>
</picture>
+15
View File
@@ -0,0 +1,15 @@
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY agent/ ./agent
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-s -w" \
-o /app/hp_agent ./agent/cmd/hp_agent
FROM scratch
COPY --from=builder /app/hp_agent /hp_agent
ENTRYPOINT ["/hp_agent"]
+40
View File
@@ -0,0 +1,40 @@
package main
import (
_ "github.com/joho/godotenv/autoload"
"github.com/tale/headplane/agent/config"
"github.com/tale/headplane/agent/tsnet"
"github.com/tale/headplane/agent/hpagent"
"log"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %s", err)
}
agent := tsnet.NewAgent(
cfg.Hostname,
cfg.TSControlURL,
cfg.TSAuthKey,
cfg.Debug,
)
agent.StartAndFetchID()
defer agent.Shutdown()
ws, err := hpagent.NewSocket(
agent,
cfg.HPControlURL,
cfg.HPAuthKey,
cfg.Debug,
)
if err != nil {
log.Fatalf("Failed to create websocket: %s", err)
}
defer ws.StopListening()
ws.StartListening()
}
@@ -1,6 +1,10 @@
package config
import "os"
import (
"os"
_ "github.com/joho/godotenv/autoload"
)
// Config represents the configuration for the agent.
type Config struct {
@@ -8,7 +12,8 @@ type Config struct {
Hostname string
TSControlURL string
TSAuthKey string
WorkDir string
HPControlURL string
HPAuthKey string
}
const (
@@ -16,7 +21,8 @@ const (
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
WorkDirEnv = "HEADPLANE_AGENT_WORK_DIR"
HPControlURLEnv = "HEADPLANE_AGENT_HP_SERVER"
HPAuthKeyEnv = "HEADPLANE_AGENT_HP_AUTHKEY"
)
// Load reads the agent configuration from environment variables.
@@ -26,7 +32,8 @@ func Load() (*Config, error) {
Hostname: os.Getenv(HostnameEnv),
TSControlURL: os.Getenv(TSControlURLEnv),
TSAuthKey: os.Getenv(TSAuthKeyEnv),
WorkDir: os.Getenv(WorkDirEnv),
HPControlURL: os.Getenv(HPControlURLEnv),
HPAuthKey: os.Getenv(HPAuthKeyEnv),
}
if os.Getenv(DebugEnv) == "true" {
@@ -41,5 +48,9 @@ func Load() (*Config, error) {
return nil, err
}
if err := validateHPReady(c); err != nil {
return nil, err
}
return c, nil
}
@@ -16,12 +16,16 @@ func validateRequired(config *Config) error {
return fmt.Errorf("%s is required", TSControlURLEnv)
}
if config.HPControlURL == "" {
return fmt.Errorf("%s is required", HPControlURLEnv)
}
if config.TSAuthKey == "" {
return fmt.Errorf("%s is required", TSAuthKeyEnv)
}
if config.WorkDir == "" {
return fmt.Errorf("%s is required", WorkDirEnv)
if config.HPAuthKey == "" {
return fmt.Errorf("%s is required", HPAuthKeyEnv)
}
return nil
@@ -34,7 +38,8 @@ func validateTSReady(config *Config) error {
testURL = testURL[:len(testURL)-1]
}
testURL = fmt.Sprintf("%s/key?v=116", testURL)
// TODO: Consequences of switching to /health (headscale only)
testURL = fmt.Sprintf("%s/key?v=109", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to TS control server: %s", err)
@@ -46,3 +51,23 @@ func validateTSReady(config *Config) error {
return nil
}
// Pings the Headplane server to make sure it's up and running
func validateHPReady(config *Config) error {
testURL := config.HPControlURL
if strings.HasSuffix(testURL, "/") {
testURL = testURL[:len(testURL)-1]
}
testURL = fmt.Sprintf("%s/healthz", testURL)
resp, err := http.Get(testURL)
if err != nil {
return fmt.Errorf("Failed to connect to HP control server: %s", err)
}
if resp.StatusCode != 200 {
return fmt.Errorf("Failed to connect to HP control server: %s", resp.Status)
}
return nil
}
+83
View File
@@ -0,0 +1,83 @@
package hpagent
import (
"encoding/json"
"log"
"sync"
"tailscale.com/tailcfg"
)
// Represents messages from the Headplane master
type RecvMessage struct {
NodeIDs []string `json:omitempty`
}
// Starts listening for messages from the Headplane master
func (s *Socket) StartListening() {
for {
_, message, err := s.ReadMessage()
if err != nil {
log.Printf("error reading message: %v", err)
return
}
var msg RecvMessage
err = json.Unmarshal(message, &msg)
if err != nil {
log.Printf("error unmarshalling message: %v", err)
continue
}
if s.Debug {
log.Printf("got message: %s", message)
}
if len(msg.NodeIDs) == 0 {
log.Printf("got a message with no node IDs? %s", message)
continue
}
// Accumulate the results since we invoke via gofunc
results := make(map[string]*tailcfg.HostinfoView)
mu := sync.Mutex{}
wg := sync.WaitGroup{}
for _, nodeID := range msg.NodeIDs {
wg.Add(1)
go func(nodeID string) {
defer wg.Done()
result, err := s.Agent.GetStatusForPeer(nodeID)
if err != nil {
log.Printf("error getting status: %v", err)
return
}
if result == nil {
return
}
mu.Lock()
results[nodeID] = result
mu.Unlock()
}(nodeID)
}
wg.Wait()
// Send the results back to the Headplane master
err = s.SendStatus(results)
if err != nil {
log.Printf("error sending status: %v", err)
return
}
if s.Debug {
log.Printf("sent status: %s", results)
}
}
}
// Stops listening for messages from the Headplane master
func (s *Socket) StopListening() {
s.Close()
}
+11
View File
@@ -0,0 +1,11 @@
package hpagent
import (
"tailscale.com/tailcfg"
)
// Sends the status to the Headplane master
func (s *Socket) SendStatus(status map[string]*tailcfg.HostinfoView) error {
err := s.WriteJSON(status)
return err
}
+63
View File
@@ -0,0 +1,63 @@
package hpagent
import (
"fmt"
"log"
"net/http"
"net/url"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
)
type Socket struct {
*websocket.Conn
Debug bool
Agent *tsnet.TSAgent
}
// Creates a new websocket connection to the Headplane server.
func NewSocket(agent *tsnet.TSAgent, controlURL, authKey string, debug bool) (*Socket, error) {
wsURL, err := httpToWs(controlURL)
if err != nil {
return nil, err
}
headers := http.Header{}
headers.Add("X-Headplane-Tailnet-ID", agent.ID)
auth := fmt.Sprintf("Bearer %s", authKey)
headers.Add("Authorization", auth)
log.Printf("dialing websocket at %s", wsURL)
ws, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
return nil, err
}
return &Socket{ws, debug, agent}, nil
}
// We need to convert the control URL to a websocket URL
func httpToWs(controlURL string) (string, error) {
u, err := url.Parse(controlURL)
if err != nil {
return "", err
}
if u.Scheme == "http" {
u.Scheme = "ws"
} else if u.Scheme == "https" {
u.Scheme = "wss"
} else {
return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
}
// We also need to append /_dial to the path
if u.Path[len(u.Path)-1] != '/' {
u.Path += "/"
}
u.Path += "_dial"
return u.String(), nil
}
+47
View File
@@ -0,0 +1,47 @@
package tsnet
import (
"context"
"fmt"
"log"
"strings"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"go4.org/mem"
)
// Returns the raw hostinfo for a peer based on node ID.
func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
if !strings.HasPrefix(id, "nodekey:") {
return nil, fmt.Errorf("invalid node ID: %s", id)
}
if s.Debug {
log.Printf("querying peer state for %s", id)
}
status, err := s.Lc.Status(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get status: %w", err)
}
nodeKey, err := key.ParseNodePublicUntyped(mem.S(id[8:]))
peer := status.Peer[nodeKey]
if peer == nil {
// Check if we are on Self.
if status.Self.PublicKey == nodeKey {
peer = status.Self
} else {
return nil, nil
}
}
ip := peer.TailscaleIPs[0].String()
whois, err := s.Lc.WhoIs(context.Background(), ip)
if err != nil {
return nil, fmt.Errorf("failed to get whois: %w", err)
}
return &whois.Node.Hostinfo, nil
}
+61
View File
@@ -0,0 +1,61 @@
package tsnet
import (
"context"
"fmt"
"log"
"os"
"tailscale.com/client/tailscale"
"tailscale.com/tsnet"
)
// Wrapper type so we can add methods to the server.
type TSAgent struct {
*tsnet.Server
Lc *tailscale.LocalClient
ID string
Debug bool
}
// Creates a new tsnet agent and returns an instance of the server.
func NewAgent(hostname, controlURL, authKey string, debug bool) *TSAgent {
s := &tsnet.Server{
Hostname: hostname,
ControlURL: controlURL,
AuthKey: authKey,
Logf: func(string, ...interface{}) {}, // Disabled by default
}
if debug {
s.Logf = log.New(
os.Stderr,
fmt.Sprintf("[DBG:%s] ", hostname),
log.LstdFlags,
).Printf
}
return &TSAgent{s, nil, "", debug}
}
// Starts the tsnet agent and sets the node ID.
func (s *TSAgent) StartAndFetchID() {
// Waits until the agent is up and running.
status, err := s.Up(context.Background())
if err != nil {
log.Fatalf("Failed to start agent: %v", err)
}
s.Lc, err = s.LocalClient()
if err != nil {
log.Fatalf("Failed to create local client: %v", err)
}
log.Printf("Agent running with ID: %s", status.Self.PublicKey)
s.ID = string(status.Self.ID)
}
// Shuts down the tsnet agent.
func (s *TSAgent) Shutdown() {
s.Close()
}
+28 -39
View File
@@ -1,54 +1,46 @@
import { Check, Copy, Info } from 'lucide-react';
import { Check, Copy } from 'lucide-react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
import Tooltip from './Tooltip';
export interface AttributeProps {
name: string;
value: string;
tooltip?: string;
isCopyable?: boolean;
link?: string;
suppressHydrationWarning?: boolean;
}
export default function Attribute({
name,
value,
tooltip,
link,
isCopyable,
suppressHydrationWarning,
}: AttributeProps) {
return (
<dl className="flex gap-1 items-center text-sm">
<dt
className={cn(
'w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0',
'text-headplane-500 dark:text-headplane-400',
tooltip ? 'flex items-center gap-1' : undefined,
<dl className="flex items-center w-full gap-x-1">
<dt className="font-semibold w-1/4 shrink-0 text-sm">
{link ? (
<a className="hover:underline" href={link}>
{name}
</a>
) : (
name
)}
>
{name}
{tooltip ? (
<Tooltip>
<Info className="size-4" />
<Tooltip.Body>{tooltip}</Tooltip.Body>
</Tooltip>
) : undefined}
</dt>
<dd
suppressHydrationWarning={suppressHydrationWarning}
className={cn(
'min-w-0 px-1.5 py-1 rounded-lg border border-transparent',
...(isCopyable
? [
'cursor-pointer hover:shadow-xs',
'hover:bg-headplane-50 dark:hover:bg-headplane-800',
'hover:border-headplane-100 dark:hover:border-headplane-700',
]
: []),
'rounded-lg truncate w-full px-2.5 py-1 text-sm',
'flex items-center gap-x-1',
'focus-within:outline-none focus-within:ring-2',
isCopyable && 'hover:bg-headplane-100 dark:hover:bg-headplane-800',
)}
>
{isCopyable ? (
<button
type="button"
className="flex items-center gap-1.5 relative min-w-0 w-full"
className="w-full flex items-center gap-x-1 outline-none"
onClick={async (event) => {
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
@@ -56,7 +48,7 @@ export default function Attribute({
}
await navigator.clipboard.writeText(value);
toast(`Copied ${name} to clipboard`);
toast('Copied to clipboard');
setTimeout(() => {
for (const svg of svgs) {
@@ -65,20 +57,17 @@ export default function Attribute({
}, 1000);
}}
>
<div suppressHydrationWarning className="truncate">
<p
suppressHydrationWarning={suppressHydrationWarning}
className="truncate"
>
{value}
</div>
{isCopyable ? (
<div>
<Check className="size-4 hidden data-copied:block" />
<Copy className="size-4 block data-copied:hidden" />
</div>
) : undefined}
</p>
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
</button>
) : (
<div className="relative min-w-0 truncate" suppressHydrationWarning>
{value}
</div>
value
)}
</dd>
</dl>
+1 -1
View File
@@ -20,7 +20,7 @@ export default function Button({ variant = 'light', ...props }: ButtonProps) {
{...buttonProps}
className={cn(
'w-fit text-sm rounded-xl px-3 py-2',
'focus:outline-hidden focus:ring-3',
'focus:outline-none focus:ring',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
+1 -1
View File
@@ -15,7 +15,7 @@ function Card({ variant = 'raised', ...props }: Props) {
'w-full max-w-md rounded-3xl p-5',
variant === 'flat'
? 'bg-transparent shadow-none'
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-xs',
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-sm',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)}
+2 -2
View File
@@ -17,10 +17,10 @@ export default function Chip({
return (
<span
className={cn(
'h-5 text-xs py-0.5 px-1 rounded-md text-nowrap',
'text-xs px-2 py-0.5 rounded-full',
'text-headplane-700 dark:text-headplane-100',
'bg-headplane-100 dark:bg-headplane-700',
'inline-flex items-center gap-x-1',
leftIcon || rightIcon ? 'inline-flex items-center gap-x-1' : '',
className,
)}
>
+5 -5
View File
@@ -5,7 +5,7 @@ import toast from '~/utils/toast';
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
children: string | string[] | number;
children: string | string[];
}
export default function Code({ isCopyable, children, className }: CodeProps) {
@@ -13,7 +13,7 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
<code
className={cn(
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
'rounded-lg focus-within:outline-hidden focus-within:ring-2',
'rounded-lg focus-within:outline-none focus-within:ring-2',
isCopyable && 'relative pr-7',
className,
)}
@@ -21,6 +21,7 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
{children}
{isCopyable && (
<button
type="button"
className="bottom-0 right-0 absolute"
onClick={async (event) => {
const text = Array.isArray(children) ? children.join('') : children;
@@ -39,10 +40,9 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
}
}, 1000);
}}
type="button"
>
<Check className="h-4.5 w-4.5 p-1 hidden data-copied:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-copied:hidden" />
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
</button>
)}
</code>
+6 -6
View File
@@ -107,11 +107,6 @@ function Panel(props: DialogPanelProps) {
return (
<Form
{...dialogProps}
className={cn(
'outline-hidden rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
method={method ?? 'POST'}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
@@ -119,7 +114,12 @@ function Panel(props: DialogPanelProps) {
close?.();
}}
method={method ?? 'POST'}
ref={ref}
className={cn(
'outline-none rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
>
<Card className="w-full max-w-lg" variant="flat">
{children}
@@ -130,9 +130,9 @@ function Panel(props: DialogPanelProps) {
<>
<Button onPress={close}>Cancel</Button>
<Button
isDisabled={isDisabled}
type="submit"
variant={variant === 'destructive' ? 'danger' : 'heavy'}
isDisabled={isDisabled}
>
Confirm
</Button>
+89
View File
@@ -0,0 +1,89 @@
import { AlertIcon } from '@primer/octicons-react';
import { isRouteErrorResponse, useRouteError } from 'react-router';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import Card from './Card';
interface Props {
type?: 'full' | 'embedded';
}
function getMessage(error: Error | unknown): {
title: string;
message: string;
} {
if (error instanceof ResponseError) {
if (error.responseObject?.message) {
return {
title: 'Headscale Error',
message: String(error.responseObject.message),
};
}
return {
title: 'Headscale Error',
message: error.response,
};
}
if (!(error instanceof Error)) {
return {
title: 'Unknown Error',
message: String(error),
};
}
let rootError = error;
// Traverse the error chain to find the root cause
if (error.cause) {
rootError = error.cause as Error;
while (rootError.cause) {
rootError = rootError.cause as Error;
}
}
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
return {
title: 'Errors',
message: rootError.errors.map((error) => error.message).join('\n'),
};
}
return {
title: 'Error',
message: rootError.message,
};
}
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const { title, message } = getMessage(error);
return (
<div
className={cn(
'flex items-center justify-center',
type === 'embedded'
? 'pointer-events-none mt-24'
: 'fixed inset-0 h-screen w-screen z-50',
)}
>
<Card>
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">
{routing ? error.status : title}
</Card.Title>
<AlertIcon className="w-12 h-12 text-red-500" />
</div>
<Card.Text
className={cn('mt-4 text-lg', routing ? 'font-normal' : 'font-mono')}
>
{routing ? error.data.message : message}
</Card.Text>
</Card>
</div>
);
}
+31 -53
View File
@@ -1,71 +1,49 @@
import { CircleX } from 'lucide-react';
import Link from '~/components/Link';
import cn from '~/utils/cn';
interface FooterProps {
url: string;
debug: boolean;
healthy: boolean;
}
export default function Footer({ url, debug, healthy }: FooterProps) {
export default function Footer({ url, debug }: FooterProps) {
return (
<footer
className={cn(
'fixed w-full bottom-0 left-0 z-40 h-12',
'flex items-center justify-center',
'bg-headplane-50 dark:bg-headplane-950',
'fixed bottom-0 left-0 z-40 w-full h-14',
'flex flex-col justify-center gap-1 shadow-inner',
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'dark:border-t dark:border-headplane-800',
)}
>
<div
className={cn(
'grid grid-rows-1 items-center container mx-auto',
!healthy && 'md:grid-cols-[1fr_auto] grid-cols-1',
)}
>
<div
className={cn('text-xs leading-none', !healthy && 'hidden md:block')}
<p className="container text-xs">
Headplane is entirely free to use. If you find it useful, consider{' '}
<Link
to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors"
>
<p>
Headplane is free. Please consider{' '}
<Link
to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors"
>
donating
</Link>{' '}
to support development.{' '}
</p>
<p className="opacity-75">
Version: {__VERSION__}
{' — '}
Connecting to{' '}
<button
type="button"
tabIndex={0} // Allows keyboard focus
className={cn(
'blur-sm hover:blur-none focus:blur-none transition',
'focus:outline-hidden focus:ring-2 rounded-xs',
)}
>
{url}
</button>
{debug && ' (Debug mode enabled)'}
</p>
</div>
{!healthy ? (
<div
className={cn(
'flex gap-1.5 items-center p-2 rounded-xl text-sm',
'bg-red-500 text-white font-semibold',
)}
>
<CircleX size={16} strokeWidth={3} />
<p className="text-nowrap">Headscale is unreachable</p>
</div>
) : undefined}
</div>
donating
</Link>{' '}
to support development.{' '}
</p>
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' — '}
Connecting to{' '}
<button
type="button"
tabIndex={0} // Allows keyboard focus
className={cn(
'blur-sm hover:blur-none focus:blur-none transition',
'focus:outline-none focus:ring-2 rounded-sm',
)}
>
{url}
</button>
{/* Connecting to <strong className="blur-xs hover:blur-none">{url}</strong> */}
{debug && ' (Debug mode enabled)'}
</p>
</footer>
);
}
+22 -22
View File
@@ -2,13 +2,13 @@ import {
CircleUser,
Globe2,
Lock,
PlaneTakeoff,
Server,
Settings,
Users,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { NavLink, useSubmit } from 'react-router';
import Logo from '~/components/Logo';
import Menu from '~/components/Menu';
import { AuthSession } from '~/server/web/sessions';
import cn from '~/utils/cn';
@@ -42,18 +42,18 @@ function TabLink({ name, to, icon }: TabLinkProps) {
return (
<div className="relative py-2">
<NavLink
to={to}
prefetch="intent"
className={({ isActive }) =>
cn(
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5',
'after:absolute after:bottom-0 after:left-3 after:right-3',
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
'focus:outline-hidden focus:ring-3',
'focus:outline-none focus:ring',
isActive ? 'after:visible' : 'after:invisible',
)
}
prefetch="intent"
to={to}
>
{icon} {name}
</NavLink>
@@ -64,13 +64,13 @@ function TabLink({ name, to, icon }: TabLinkProps) {
function Link({ href, text }: LinkProps) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className={cn(
'hidden sm:block hover:underline text-sm',
'focus:outline-hidden focus:ring-3 rounded-md',
'focus:outline-none focus:ring rounded-md',
)}
href={href}
rel="noreferrer"
target="_blank"
>
{text}
</a>
@@ -91,7 +91,7 @@ export default function Header(data: Props) {
>
<div className="container flex items-center justify-between py-4">
<div className="flex items-center gap-x-2">
<Logo />
<PlaneTakeoff />
<h1 className="text-2xl font-semibold">headplane</h1>
</div>
<div className="flex items-center gap-x-4">
@@ -101,21 +101,20 @@ export default function Header(data: Props) {
{data.user ? (
<Menu>
<Menu.IconButton
className={cn(data.user.picture ? 'p-0' : '')}
label="User"
className={cn(data.user.picture ? 'p-0' : '')}
>
{data.user.picture ? (
<img
src={data.user.picture}
alt={data.user.name}
className="w-8 h-8 rounded-full"
src={data.user.picture}
/>
) : (
<CircleUser />
)}
</Menu.IconButton>
<Menu.Panel
disabledKeys={['profile']}
onAction={(key) => {
if (key === 'logout') {
submit(
@@ -127,6 +126,7 @@ export default function Header(data: Props) {
);
}
}}
disabledKeys={['profile']}
>
<Menu.Section>
<Menu.Item key="profile" textValue="Profile">
@@ -148,39 +148,39 @@ export default function Header(data: Props) {
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
{data.access.machines ? (
<TabLink
icon={<Server className="w-5" />}
name="Machines"
to="/machines"
name="Machines"
icon={<Server className="w-5" />}
/>
) : undefined}
{data.access.users ? (
<TabLink
icon={<Users className="w-5" />}
name="Users"
to="/users"
name="Users"
icon={<Users className="w-5" />}
/>
) : undefined}
{data.access.policy ? (
<TabLink
icon={<Lock className="w-5" />}
name="Access Control"
to="/acls"
name="Access Control"
icon={<Lock className="w-5" />}
/>
) : undefined}
{data.configAvailable ? (
<>
{data.access.dns ? (
<TabLink
icon={<Globe2 className="w-5" />}
name="DNS"
to="/dns"
name="DNS"
icon={<Globe2 className="w-5" />}
/>
) : undefined}
{data.access.settings ? (
<TabLink
icon={<Settings className="w-5" />}
name="Settings"
to="/settings"
name="Settings"
icon={<Settings className="w-5" />}
/>
) : undefined}
</>
+1 -1
View File
@@ -25,7 +25,7 @@ export default function IconButton({
aria-label={props.label}
className={cn(
'rounded-full flex items-center justify-center p-1',
'focus:outline-hidden focus:ring-3',
'focus:outline-none focus:ring',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
+11 -20
View File
@@ -8,18 +8,11 @@ export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
labelHidden?: boolean;
isRequired?: boolean;
className?: string;
isInvalid?: boolean;
errorMessage?: string;
}
// TODO: Custom isInvalid logic for custom error messages
export default function Input(props: InputProps) {
const {
label,
labelHidden,
className,
isInvalid: customIsInvalid,
errorMessage,
} = props;
const { label, labelHidden, className } = props;
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
@@ -28,7 +21,7 @@ export default function Input(props: InputProps) {
inputProps,
descriptionProps,
errorMessageProps,
isInvalid: ariaIsInvalid,
isInvalid,
validationErrors,
} = useTextField(
{
@@ -39,18 +32,16 @@ export default function Input(props: InputProps) {
ref,
);
const isInvalid = customIsInvalid ?? ariaIsInvalid;
return (
<div className="flex flex-col w-full">
<div className="flex flex-col w-full" aria-label={label}>
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
labelHidden && 'sr-only',
)}
htmlFor={id}
>
{label}
{props.isRequired && (
@@ -59,15 +50,15 @@ export default function Input(props: InputProps) {
</label>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
className={cn(
'rounded-xl px-3 py-2',
'focus:outline-hidden focus:ring-3',
'focus:outline-none focus:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
ref={ref}
required={props.isRequired}
/>
{props.description && (
<div
@@ -80,14 +71,14 @@ export default function Input(props: InputProps) {
{props.description}
</div>
)}
{isInvalid ? (
{isInvalid && (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{errorMessage ?? validationErrors.join(' ')}
{validationErrors.join(' ')}
</div>
) : null}
)}
</div>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function Link({
'inline-flex items-center gap-x-0.5',
'text-blue-500 hover:text-blue-700',
'dark:text-blue-400 dark:hover:text-blue-300',
'focus:outline-hidden focus:ring-3 rounded-md',
'focus:outline-none focus:ring rounded-md',
className,
)}
>
-10
View File
@@ -1,10 +0,0 @@
import cn from '~/utils/cn';
import LogoSvg from '../../public/logo-light.svg';
export interface LogoProps {
className?: string;
}
export default function Logo({ className }: LogoProps) {
return <img alt="Logo" className={cn(className)} src={LogoSvg} />;
}
+2 -2
View File
@@ -74,7 +74,7 @@ function Panel(props: MenuPanelProps) {
<ul
{...menuProps}
ref={ref}
className="pt-1 pb-1 shadow-2xs rounded-md min-w-[200px] focus:outline-hidden"
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
>
{[...state.collection].map((item) => (
<MenuSection
@@ -149,7 +149,7 @@ function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
ref={ref}
className={cn(
'py-2 px-3 mx-1 rounded-lg',
'focus:outline-hidden select-none',
'focus:outline-none select-none',
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
isDisabled
? 'text-headplane-400 dark:text-headplane-600'
+5 -34
View File
@@ -1,45 +1,16 @@
import {
CircleAlert,
CircleSlash2,
LucideProps,
TriangleAlert,
} from 'lucide-react';
import { CircleSlash2 } from 'lucide-react';
import React from 'react';
import Card from '~/components/Card';
export interface NoticeProps {
children: React.ReactNode;
title?: string;
variant?: 'default' | 'error' | 'warning';
icon?: React.ReactElement<LucideProps>;
}
export default function Notice({
children,
title,
variant,
icon,
}: NoticeProps) {
export default function Notice({ children }: NoticeProps) {
return (
<Card variant="flat" className="max-w-2xl my-6">
<div className="flex items-center justify-between">
{title ? (
<Card.Title className="text-xl mb-0">{title}</Card.Title>
) : undefined}
{!variant && icon ? icon : iconForVariant(variant)}
</div>
<Card.Text className="mt-4">{children}</Card.Text>
<Card className="flex w-full max-w-full gap-4 font-semibold">
<CircleSlash2 />
{children}
</Card>
);
}
function iconForVariant(variant?: 'default' | 'error' | 'warning') {
switch (variant) {
case 'error':
return <TriangleAlert className="text-red-500" />;
case 'warning':
return <CircleAlert className="text-yellow-500" />;
default:
return <CircleSlash2 />;
}
}
+2 -2
View File
@@ -50,7 +50,7 @@ export default function NumberInput(props: InputProps) {
{...groupProps}
className={cn(
'flex items-center gap-1 rounded-xl pr-1',
'focus-within:outline-hidden focus-within:ring-3',
'focus-within:outline-none focus-within:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
@@ -60,7 +60,7 @@ export default function NumberInput(props: InputProps) {
required={props.isRequired}
ref={ref}
id={id}
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-hidden"
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-none"
/>
<input type="hidden" name={name} value={state.numberValue} />
<IconButton
+1 -1
View File
@@ -52,7 +52,7 @@ function Option({ item, state }: OptionsOptionProps) {
className={cn(
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-hidden focus:ring-3 z-10',
'focus:outline-none focus:ring z-10',
'border border-headplane-100 dark:border-headplane-800',
)}
>
+1 -1
View File
@@ -34,7 +34,7 @@ export default function Popover(props: PopoverProps) {
{...popoverProps}
ref={ref}
className={cn(
'z-10 shadow-xs rounded-xl',
'z-10 shadow-sm rounded-xl',
'bg-white dark:bg-headplane-900',
'border border-headplane-200 dark:border-headplane-800',
className,
+18 -20
View File
@@ -50,39 +50,36 @@ function Select(props: SelectProps) {
<div className={cn('flex flex-col', props.className)}>
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
htmlFor={id}
>
{props.label}
</label>
<div
className={cn(
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
'flex rounded-xl focus:outline-none focus-within:ring',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
props.isInvalid && 'ring-red-400',
)}
>
<input
{...inputProps}
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
data-1p-ignore
id={id}
ref={inputRef}
id={id}
className="outline-none px-3 py-2 rounded-l-xl w-full bg-transparent"
data-1p-ignore
/>
<button
{...buttonProps}
ref={buttonRef}
className={cn(
'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
props.isDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)}
ref={buttonRef}
>
<ChevronDown className="p-0.5" />
</button>
@@ -100,12 +97,12 @@ function Select(props: SelectProps) {
)}
{state.isOpen && (
<Popover
className="w-full max-w-xs"
popoverRef={popoverRef}
triggerRef={inputRef}
state={state}
isNonModal
placement="bottom start"
popoverRef={popoverRef}
state={state}
triggerRef={inputRef}
className="w-full max-w-xs"
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
@@ -115,22 +112,23 @@ function Select(props: SelectProps) {
}
interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef: React.RefObject<HTMLUListElement | null>;
listBoxRef?: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
}
function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props;
const { listBoxProps } = useListBox(props, state, listBoxRef);
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null);
const { listBoxProps } = useListBox(props, state, ref);
return (
<ul
{...listBoxProps}
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
ref={listBoxRef}
className="w-full max-h-72 overflow-auto outline-none pt-1"
>
{[...state.collection].map((item) => (
<Option item={item} key={item.key} state={state} />
<Option key={item.key} item={item} state={state} />
))}
</ul>
);
@@ -154,16 +152,16 @@ function Option({ item, state }: OptionProps) {
return (
<li
{...optionProps}
ref={ref}
className={cn(
'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1',
'focus:outline-hidden select-none',
'focus:outline-none select-none',
isFocused || isSelected
? 'bg-headplane-100/50 dark:bg-headplane-800'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
isDisabled && 'text-headplane-300 dark:text-headplane-600',
)}
ref={ref}
>
{item.rendered}
{isSelected && <Check className="p-0.5" />}
+2 -5
View File
@@ -11,7 +11,6 @@ import cn from '~/utils/cn';
export interface SwitchProps extends AriaSwitchProps {
label: string;
className?: string;
switchClassName?: string;
}
export default function Switch(props: SwitchProps) {
@@ -46,16 +45,14 @@ export default function Switch(props: SwitchProps) {
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
isFocusVisible && 'ring-2',
props.isDisabled && 'opacity-50',
props.className,
)}
>
<span
className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-50 ease-in-out',
'translate-x-0 group-selected:translate-x-full',
state.isSelected && 'translate-x-full',
props.switchClassName,
'translate-x-0 group-selected:translate-x-[100%]',
state.isSelected && 'translate-x-[100%]',
)}
/>
</div>
+6 -6
View File
@@ -23,15 +23,15 @@ function Tabs({ label, className, ...props }: TabsProps) {
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className={cn(
'flex items-center rounded-t-xl w-fit max-w-full overflow-x-auto',
'flex items-center rounded-t-xl w-fit',
'border-headplane-100 dark:border-headplane-800',
'border-t border-x',
)}
ref={ref}
>
{[...state.collection].map((item) => (
<Tab item={item} key={item.key} state={state} />
<Tab key={item.key} item={item} state={state} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
@@ -52,14 +52,14 @@ function Tab({ item, state }: TabsTabProps) {
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-hidden focus:ring-3 z-10',
'focus:outline-none focus:ring z-10',
'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
)}
ref={ref}
>
{rendered}
</div>
@@ -76,11 +76,11 @@ function TabsPanel({ state, ...props }: TabsPanelProps) {
return (
<div
{...tabPanelProps}
ref={ref}
className={cn(
'w-full overflow-clip rounded-b-xl rounded-r-xl',
'border border-headplane-100 dark:border-headplane-800',
)}
ref={ref}
>
{state.selectedItem?.props.children}
</div>
+2 -2
View File
@@ -38,7 +38,7 @@ function Tooltip(props: TooltipProps) {
{...triggerProps}
className={cn(
'flex items-center justify-center',
'focus:outline-hidden focus:ring-3 rounded-xl',
'focus:outline-none focus:ring rounded-xl',
)}
>
{component}
@@ -65,7 +65,7 @@ function Body({ state, className, ...props }: TooltipBodyProps) {
{...mergeProps(props, tooltipProps)}
className={cn(
'absolute z-50 p-3 top-full mt-1',
'outline-hidden rounded-3xl text-sm w-48',
'outline-none rounded-3xl text-sm w-48',
'bg-white dark:bg-headplane-950',
'text-black dark:text-white',
'shadow-lg dark:shadow-md rounded-xl',
-211
View File
@@ -1,211 +0,0 @@
import { AlertCircle } from 'lucide-react';
import { isRouteErrorResponse } from 'react-router';
import {
isApiError,
isConnectionError,
} from '~/server/headscale/api/error-client';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
import Link from './Link';
export function getErrorMessage(error: Error | unknown): {
title: string;
jsxMessage: React.ReactNode;
} {
if (isRouteErrorResponse(error)) {
if (isApiError(error.data)) {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
title: 'Headscale API Error',
jsxMessage: (
<>
<Card.Text>
There was an error communicating with the Headscale API.
<br />
The server responded with a status code of{' '}
<strong>{statusCode}</strong>, indicating a server-side issue.
Please check the Headscale server status and try again later.
</Card.Text>
{(error.data.data != null || error.data.rawData != null) && (
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{error.data.data != null ? (
<code>{JSON.stringify(error.data.data, null, 2)}</code>
) : (
<code>{error.data.rawData}</code>
)}
</pre>
)}
</>
),
};
}
const authError =
error.data.statusCode === 401 || error.data.statusCode === 403;
return {
title: 'Invalid response from Headscale API',
jsxMessage: (
<>
<Card.Text className="leading-snug">
The Headscale API returned an unexpected response.
{authError ? (
<>
{' '}
The status code indicates an authentication error. Please
verify your API key and Headplane configuration.
</>
) : (
<>
{' '}
You may be using an unsupported version of Headscale or this
may be a bug.
</>
)}
</Card.Text>
<ul className="list-disc list-inside mt-2">
<li>
Request URL: <Code>{requestUrl}</Code>
</li>
<li>
Status Code:{' '}
<Code>
{/* @ts-expect-error */}
{data === null ? (
<>
{statusCode} {rawData}
</>
) : (
<>
{statusCode} {error.statusText}
</>
)}
</Code>
</li>
</ul>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
<code>{JSON.stringify(error.data, null, 2)}</code>
</pre>
</>
),
};
}
if (isConnectionError(error.data)) {
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
return {
title: 'Cannot connect to Headscale API',
jsxMessage: (
<>
<Card.Text className="leading-snug">
Headplane was unable to reach the Headscale API. Please check your
network setup and configuration to ensure Headplane is able to
connect.
</Card.Text>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{requestUrl}
<br />
{errorCode}: {errorMessage}
{extraData != null && (
<>
<br />
<br />
<code>{JSON.stringify(extraData, null, 2)}</code>
</>
)}
</pre>
</>
),
};
}
return {
title: `Error ${error.status}`,
jsxMessage: (
<>
There was an error processing your request.
<br />
Status Code: <strong>{error.status}</strong>
<br />
Status Text: <strong>{error.data}</strong>
</>
),
};
}
if (!(error instanceof Error)) {
return {
title: 'Unexpected Error',
jsxMessage: (
<>
<Card.Text>
An unexpected error occurred which is most likely a bug. Please
consider reporting filing an issue on the{' '}
<Link
name="Headplane GitHub"
to="https://github.com/tale/headplane/issues"
>
Headplane GitHub
</Link>{' '}
repository with the details below.
</Card.Text>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
<code>{JSON.stringify(error, null, 2)}</code>
</pre>
</>
),
};
}
// Traverse the error chain to find the root cause
let rootError = error;
if (error.cause != null) {
rootError = error.cause as Error;
while (rootError.cause != null) {
rootError = rootError.cause as Error;
}
}
// TODO: If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
throw new Error('AggregateError handling not implemented yet');
}
return {
title:
rootError.name.length > 0 && rootError.name !== 'Error'
? `Error: ${rootError.name}`
: 'Error',
jsxMessage: rootError.message,
};
}
interface ErrorBannerProps {
error: unknown;
className?: string;
}
export function ErrorBanner({ error, className }: ErrorBannerProps) {
const { title, jsxMessage } = getErrorMessage(error);
return (
<Card className={cn('w-screen', className)} variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>{title}</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{jsxMessage}
</Card>
);
}
-32
View File
@@ -1,32 +0,0 @@
import { Info } from 'lucide-react';
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export interface ExitNodeTagProps {
isEnabled?: boolean;
}
export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
return (
<Tooltip>
<Chip
text="Exit Node"
className={cn(
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
)}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<>This machine is acting as an exit node.</>
) : (
<>
This machine is requesting to be used as an exit node. Review this
from the "Edit route settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
</Tooltip>
);
}
-42
View File
@@ -1,42 +0,0 @@
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export interface ExpiryTagProps {
variant: 'expired' | 'no-expiry';
expiry?: string;
}
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
return (
<Tooltip>
<Chip
text={
variant === 'expired'
? `Expired ${formatter.format(new Date(expiry!))}`
: 'No expiry'
}
className="bg-headplane-200 text-headplane-800 dark:bg-headplane-800 dark:text-headplane-200"
/>
<Tooltip.Body>
{variant === 'expired' ? (
<>
This machine is expired and will not be able to connect to the
network. Re-authenticate with Tailscale on the machine to re-enable
it.
</>
) : (
<>
This machine has key expiry disabled and will never need to
re-authenticate.
</>
)}
</Tooltip.Body>
</Tooltip>
);
}
-20
View File
@@ -1,20 +0,0 @@
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export function HeadplaneAgentTag() {
return (
<Tooltip>
<Chip
text="Headplane Agent"
className={cn(
'bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300',
)}
/>
<Tooltip.Body>
This machine is running the Headplane agent, which allows it to provide
host information in the web UI.
</Tooltip.Body>
</Tooltip>
);
}
-32
View File
@@ -1,32 +0,0 @@
import { Info } from 'lucide-react';
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export interface SubnetTagProps {
isEnabled?: boolean;
}
export function SubnetTag({ isEnabled }: SubnetTagProps) {
return (
<Tooltip>
<Chip
text="Subnets"
className={cn(
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
)}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<>This machine advertises subnet routes.</>
) : (
<>
This machine has unadvertised subnet routes. Review this from the
"Edit route settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
</Tooltip>
);
}
-21
View File
@@ -1,21 +0,0 @@
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export function TailscaleSSHTag() {
return (
<Tooltip>
<Chip
text="Tailscale SSH"
className={cn(
'bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500',
)}
/>
<Tooltip.Body>
This machine advertises Tailscale SSH, which allows you to authenticate
SSH credentials using your Tailscale account and via the Headplane web
UI.
</Tooltip.Body>
</Tooltip>
);
}
+6
View File
@@ -2,6 +2,12 @@ import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
if (import.meta.env.DEV) {
import('react-scan').then(({ scan }) => {
scan({ enabled: true });
});
}
startTransition(() => {
hydrateRoot(
document,
+55 -54
View File
@@ -1,65 +1,66 @@
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { isbot } from "isbot";
import { PassThrough } from "node:stream";
import { renderToPipeableStream } from "react-dom/server";
import { ServerRouter } from "react-router";
import { PassThrough } from 'node:stream';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import { AppLoadContext, EntryContext, ServerRouter } from 'react-router';
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get("user-agent");
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? "onAllReady" : "onShellReady";
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? 'onAllReady'
: 'onShellReady';
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set("Content-Type", "text/html");
responseHeaders.set('Content-Type', 'text/html');
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
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);
}
},
},
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
// biome-ignore lint/style/noParameterAssign: Lazy
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);
}
},
},
);
// Abort the rendering stream after the `streamTimeout` so it has tine to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
// Abort the rendering stream after the `streamTimeout` so it has tine to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
}
+46 -27
View File
@@ -1,30 +1,30 @@
import { Outlet, redirect } from 'react-router';
import { ErrorBanner } from '~/components/error-banner';
import { pruneEphemeralNodes } from '~/server/db/pruner';
import { isDataUnauthorizedError } from '~/server/headscale/api/error-client';
import { XCircleFillIcon } from '@primer/octicons-react';
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { Outlet, useLoaderData } from 'react-router';
import { ErrorPopup } from '~/components/Error';
import type { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import log from '~/utils/log';
import type { Route } from './+types/dashboard';
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck();
const session = await context.sessions.auth(request);
const api = context.hsApi.getRuntimeClient(session.api_key);
// MARK: The session should stay valid if Headscale isn't healthy
const healthy = await api.isHealthy();
// We shouldn't session invalidate if Headscale is down
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
if (healthy) {
try {
await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest });
await context.client.get('v1/apikey', session.get('api_key')!);
} catch (error) {
if (isDataUnauthorizedError(error)) {
log.warn(
'auth',
'Logging out %s due to expired API key',
session.user.name,
);
if (error instanceof ResponseError) {
log.debug('api', 'API Key validation failed %o', error);
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroySession(),
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
@@ -37,17 +37,36 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
}
export default function Layout() {
const { healthy } = useLoaderData<typeof loader>();
return (
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
<>
{!healthy ? (
<div
className={cn(
'fixed bottom-0 right-0 z-50 w-fit h-14',
'flex flex-col justify-center gap-1',
)}
>
<div
className={cn(
'flex items-center gap-1.5 mr-1.5 py-2 px-1.5',
'border rounded-lg text-white bg-red-500',
'border-red-600 dark:border-red-400 shadow-sm',
)}
>
<XCircleFillIcon className="w-4 h-4 text-white" />
Headscale is unreachable
</div>
</div>
) : undefined}
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
</>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="w-fit mx-auto overscroll-contain my-24">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}
+159 -69
View File
@@ -1,79 +1,169 @@
import { eq } from "drizzle-orm";
import { Outlet, redirect } from "react-router";
import Footer from "~/components/Footer";
import Header from "~/components/Header";
import { users } from "~/server/db/schema";
import { Capabilities } from "~/server/web/roles";
import { Route } from "./+types/shell";
import { CircleCheckIcon } from 'lucide-react';
import {
LoaderFunctionArgs,
Outlet,
redirect,
useLoaderData,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { User } from '~/types';
import log from '~/utils/log';
import toast from '~/utils/toast';
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const session = await context.sessions.auth(request);
if (
typeof context.oidc === "object" &&
session.user.subject !== "unknown-non-oauth" &&
!request.url.endsWith("/onboarding")
) {
const [user] = await context.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
// There is a session, but it's not valid
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
if (!user?.onboarded) {
return redirect("/onboarding");
}
}
// Onboarding is only a feature of the OIDC flow
if (context.oidc && !request.url.endsWith('/onboarding')) {
let onboarded = false;
const api = context.hsApi.getRuntimeClient(session.api_key);
const check = await context.sessions.check(request, Capabilities.ui_access);
const sessionUser = session.get('user');
if (sessionUser) {
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
onboarded = true;
} else {
try {
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
// OIDC users without ui_access go to pending approval
if (
!check &&
session.user.subject !== "unknown-non-oauth" &&
!request.url.endsWith("/onboarding")
) {
return redirect("/pending-approval");
}
if (users.length === 0) {
onboarded = false;
}
return {
config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
debug: context.config.debug,
user: session.user,
uiAccess: check,
access: {
ui: await context.sessions.check(request, Capabilities.ui_access),
dns: await context.sessions.check(request, Capabilities.read_network),
users: await context.sessions.check(request, Capabilities.read_users),
policy: await context.sessions.check(request, Capabilities.read_policy),
machines: await context.sessions.check(request, Capabilities.read_machines),
settings: await context.sessions.check(request, Capabilities.read_feature),
},
onboarding: request.url.endsWith("/onboarding"),
healthy: await api.isHealthy(),
};
} catch {
return redirect("/login", {
headers: {
"Set-Cookie": await context.sessions.destroySession(),
},
});
}
const user = users.find((u) => {
if (u.provider !== 'oidc') {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
return true;
}
return subject === sessionUser.subject;
});
if (user) {
onboarded = true;
}
} catch (e) {
// If we cannot lookup users, just assume our user is onboarded
log.debug('api', 'Failed to lookup users %o', e);
onboarded = true;
}
}
}
if (!onboarded) {
return redirect('/onboarding');
}
}
const check = await context.sessions.check(request, Capabilities.ui_access);
return {
config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
debug: context.config.debug,
user: session.get('user'),
uiAccess: check,
access: {
ui: await context.sessions.check(request, Capabilities.ui_access),
dns: await context.sessions.check(request, Capabilities.read_network),
users: await context.sessions.check(request, Capabilities.read_users),
policy: await context.sessions.check(request, Capabilities.read_policy),
machines: await context.sessions.check(
request,
Capabilities.read_machines,
),
settings: await context.sessions.check(
request,
Capabilities.read_feature,
),
},
onboarding: request.url.endsWith('/onboarding'),
};
} catch {
// No session, so we can just return
return redirect('/login');
}
}
export default function Shell({ loaderData }: Route.ComponentProps) {
return (
<>
<Header {...loaderData} />
<Outlet />
<Footer {...loaderData} />
</>
);
export default function Shell() {
const data = useLoaderData<typeof loader>();
return (
<>
<Header {...data} />
{/* Always show the outlet if we are onboarding */}
{(data.onboarding ? true : data.uiAccess) ? (
<Outlet />
) : (
<Card className="mx-auto w-fit mt-24">
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">Connected</Card.Title>
<CircleCheckIcon className="w-10 h-10" />
</div>
<Card.Text className="my-4 text-lg">
Connect to Tailscale with your devices to access this Tailnet. Use
this command to help you get started:
</Card.Text>
<Button
className="flex text-md font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server=${data.url}`,
);
toast('Copied to clipboard');
}}
>
tailscale up --login-server={data.url}
</Button>
<p className="text-xs mt-1 opacity-50 text-center">
Click this button to copy the command.
</p>
<p className="mt-4 text-sm opacity-50">
Your account does not have access to the UI. Please contact your
administrator if you believe this is a mistake.
</p>
</Card>
)}
<Footer {...data} />
</>
);
}
-6
View File
@@ -1,6 +0,0 @@
{
"0.26.1": ["0.26.0", "0.26.1"],
"0.27.0": ["0.27.0"],
"0.27.1": ["0.27.1"],
"0.28.0": ["0.28.0"]
}
-135
View File
@@ -1,135 +0,0 @@
{
"0.26.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.26.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.28.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "b10ca7d2750405b2",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "8428b44e3a821e9e",
"DELETE /api/v1/preauthkey": "f05ea1bc8ad89a09",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
}
}
+55 -57
View File
@@ -1,71 +1,69 @@
import type { LinksFunction, MetaFunction } from "react-router";
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from "react-router";
import "@fontsource-variable/inter";
import { ExternalScripts } from "remix-utils/external-scripts";
import ProgressBar from "~/components/ProgressBar";
import ToastProvider from "~/components/ToastProvider";
import { LiveDataProvider } from "~/utils/live-data";
import { useToastQueue } from "~/utils/toast";
import type { Route } from "./+types/root";
import { ErrorBanner } from "./components/error-banner";
import stylesheet from "~/tailwind.css?url";
import type { LinksFunction, MetaFunction } from 'react-router';
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useNavigation,
} from 'react-router';
import '@fontsource-variable/inter';
import { ErrorPopup } from '~/components/Error';
import ProgressBar from '~/components/ProgressBar';
import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url';
import { LiveDataProvider } from '~/utils/live-data';
import { useToastQueue } from '~/utils/toast';
export const meta: MetaFunction = () => [
{ title: "Headplane" },
{
name: "description",
content: "A frontend for the headscale coordination server",
},
{ title: 'Headplane' },
{
name: 'description',
content: 'A frontend for the headscale coordination server',
},
];
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet },
];
export function Layout({ children }: { readonly children: React.ReactNode }) {
const toastQueue = useToastQueue();
const toastQueue = useToastQueue();
// LiveDataProvider is wrapped at the top level since dialogs and things
// that control its state are usually open in portal containers which
// are not a part of the normal React tree.
return (
<LiveDataProvider>
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<Meta />
<Links />
<link href={`${__PREFIX__}/favicon.ico`} rel="icon" />
</head>
<body className="dark:bg-headplane-900 dark:text-headplane-50 overflow-x-hidden overscroll-none">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
<Scripts />
<ExternalScripts />
</body>
</html>
</LiveDataProvider>
);
// LiveDataProvider is wrapped at the top level since dialogs and things
// that control its state are usually open in portal containers which
// are not a part of the normal React tree.
return (
<LiveDataProvider>
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
<Scripts />
</body>
</html>
</LiveDataProvider>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="flex h-screen w-screen items-center justify-center p-4">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
export function ErrorBoundary() {
return <ErrorPopup />;
}
export default function App() {
const nav = useNavigation();
const nav = useNavigation();
return (
<>
<ProgressBar isVisible={nav.state === "loading"} />
<Outlet />
</>
);
return (
<>
<ProgressBar isVisible={nav.state === 'loading'} />
<Outlet />
</>
);
}
+29 -35
View File
@@ -1,42 +1,36 @@
import { index, layout, prefix, route } from "@react-router/dev/routes";
import { index, layout, prefix, route } from '@react-router/dev/routes';
export default [
// Utility Routes
index("routes/util/redirect.ts"),
route("/healthz", "routes/util/healthz.ts"),
// Utility Routes
index('routes/util/redirect.ts'),
route('/healthz', 'routes/util/healthz.ts'),
// API Routes
...prefix("/api", [route("/info", "routes/util/info.ts")]),
// Authentication Routes
route('/login', 'routes/auth/login.tsx'),
route('/logout', 'routes/auth/logout.ts'),
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'),
// Authentication Routes
route("/login", "routes/auth/login/page.tsx"),
route("/logout", "routes/auth/logout.ts"),
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
route("/oidc/start", "routes/auth/oidc-start.ts"),
route("/pending-approval", "routes/auth/pending-approval.tsx"),
route("/ssh", "routes/ssh/console.tsx"),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
route('/onboarding', 'routes/users/onboarding.tsx'),
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'),
layout('layouts/dashboard.tsx', [
...prefix('/machines', [
index('routes/machines/overview.tsx'),
route('/:id', 'routes/machines/machine.tsx'),
]),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout("layouts/shell.tsx", [
route("/onboarding", "routes/users/onboarding.tsx"),
route("/onboarding/skip", "routes/users/onboarding-skip.tsx"),
layout("layouts/dashboard.tsx", [
...prefix("/machines", [
index("routes/machines/overview.tsx"),
route("/:id", "routes/machines/machine.tsx"),
]),
route('/users', 'routes/users/overview.tsx'),
route('/acls', 'routes/acls/overview.tsx'),
route('/dns', 'routes/dns/overview.tsx'),
route("/users", "routes/users/overview.tsx"),
route("/acls", "routes/acls/overview.tsx"),
route("/dns", "routes/dns/overview.tsx"),
...prefix("/settings", [
index("routes/settings/overview.tsx"),
route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
route("/restrictions", "routes/settings/restrictions/overview.tsx"),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
]),
...prefix('/settings', [
index('routes/settings/overview.tsx'),
route('/auth-keys', 'routes/settings/auth-keys.tsx'),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
]),
];
+72 -98
View File
@@ -1,35 +1,40 @@
import { data } from 'react-router';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
import { data400, data403 } from '~/utils/res';
// We only check capabilities here and assume it is writable
// If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening.
export async function aclAction({ request, context }: Route.ActionArgs) {
export async function aclAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_policy,
);
if (!check) {
throw data('You do not have permission to write to the ACL policy', {
status: 403,
});
throw data403('You do not have permission to write to the ACL policy');
}
// Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData();
const policyData = formData.get('policy')?.toString();
if (!policyData) {
throw data('Missing `policy` in the form data.', {
status: 400,
});
throw data400('Missing `policy` in the form data.');
}
const api = context.hsApi.getRuntimeClient(session.api_key);
try {
const { policy, updatedAt } = await api.setPolicy(policyData);
const { policy, updatedAt } = await context.client.put<{
policy: string;
updatedAt: string;
}>('v1/policy', session.get('api_key')!, {
policy: policyData,
});
return data({
success: true,
error: undefined,
@@ -37,99 +42,68 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
updatedAt,
});
} catch (error) {
if (isDataWithApiError(error)) {
const rawData = error.data.rawData;
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
if (rawData.includes('update is disabled')) {
throw data('Policy is not writable', { status: 403 });
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('update is disabled')) {
// This means the policy is not writable
throw data403('Policy is not writable');
}
const message =
error.data.data != null &&
'message' in error.data.data &&
typeof error.data.data.message === 'string'
? error.data.data.message
: undefined;
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
if (message.includes('parsing hujson')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err: hujson:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
if (message == null) {
throw error;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
// we need to reference other error messages based on API version.
if (context.hsApi.clientHelpers.isAtleast('0.27.0')) {
if (message.includes('parsing HuJSON:')) {
const cutIndex = message.indexOf('parsing HuJSON:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 16).trim()}`
: message;
if (message.includes('unmarshalling policy')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('parsing policy from bytes:')) {
const cutIndex = message.indexOf('parsing policy from bytes:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 26).trim()}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
} else {
// Pre-0.27.0 error messages
if (message.includes('parsing hujson')) {
const cutIndex = message.indexOf('err: hujson:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('unmarshalling policy')) {
const cutIndex = message.indexOf('err:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('empty policy')) {
return data(
{
success: false,
error: 'Policy error: Supplied policy was empty',
policy: undefined,
updatedAt: undefined,
},
400,
);
}
}
+30 -14
View File
@@ -1,7 +1,8 @@
import { data } from 'react-router';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { LoaderFunctionArgs } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
import { data403 } from '~/utils/res';
// The logic for deciding policy factors is very complicated because
// there are so many factors that need to be accounted for:
@@ -10,13 +11,15 @@ import type { Route } from './+types/overview';
// 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available.
export async function aclLoader({ request, context }: Route.LoaderArgs) {
// TODO: Consider adding back file editing mode instead of database
export async function aclLoader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_policy);
if (!check) {
throw data('You do not have permission to read the ACL policy.', {
status: 403,
});
throw data403('You do not have permission to read the ACL policy.');
}
const flags = {
@@ -27,22 +30,35 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
};
// Try to load the ACL policy from the API.
const api = context.hsApi.getRuntimeClient(session.api_key);
try {
const { policy, updatedAt } = await api.getPolicy();
const { policy, updatedAt } = await context.client.get<{
policy: string;
updatedAt: string | null;
}>('v1/policy', session.get('api_key')!);
// Successfully loaded the policy, mark it as readable
// If `updatedAt` is null, it means the policy is in file mode.
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
} catch (error) {
if (isDataWithApiError(error)) {
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
if (error.data.rawData.includes('acl policy not found')) {
flags.policy = '';
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('acl policy not found')) {
// This means the policy has never been initiated, and we can
// write to it to get it started or ignore it.
flags.policy = ''; // Start with an empty policy
flags.writable = true;
return flags;
}
return flags;
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}
+8 -8
View File
@@ -38,14 +38,14 @@ export function Editor(props: EditorProps) {
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => (
<CodeMirror
editable={!props.isDisabled}
extensions={[shopify.jsonc()]} // Allow editing unless disabled
height="100%" // Use readOnly if disabled
onChange={(value) => props.onChange(value)}
readOnly={props.isDisabled}
value={props.value}
editable={!props.isDisabled} // Allow editing unless disabled
readOnly={props.isDisabled} // Use readOnly if disabled
height="100%"
extensions={[shopify.jsonc()]}
style={{ height: '100%' }}
theme={light ? xcodeLight : xcodeDark}
value={props.value}
onChange={(value) => props.onChange(value)}
/>
)}
</ClientOnly>
@@ -92,14 +92,14 @@ export function Differ(props: DifferProps) {
{() => (
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
<Merge.Original
extensions={[shopify.jsonc()]}
readOnly
value={props.left}
extensions={[shopify.jsonc()]}
/>
<Merge.Modified
extensions={[shopify.jsonc()]}
readOnly
value={props.right}
extensions={[shopify.jsonc()]}
/>
</Merge>
)}
+45
View File
@@ -0,0 +1,45 @@
import { AlertIcon } from '@primer/octicons-react';
import React from 'react';
import Card from '~/components/Card';
interface NoticeViewProps {
title: string;
children: React.ReactNode;
}
export function NoticeView({ children, title }: NoticeViewProps) {
return (
<Card variant="flat" className="max-w-2xl my-8">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">{title}</Card.Title>
<AlertIcon className="w-8 h-8 text-yellow-500" />
</div>
<Card.Text className="mt-4">{children}</Card.Text>
</Card>
);
}
interface ErrorViewProps {
children: string;
}
export function ErrorView({ children }: ErrorViewProps) {
const [title, ...rest] = children.split(':');
const formattedMessage = rest.length > 0 ? rest.join(':').trim() : children;
return (
<Card variant="flat" className="max-w-2xl mb-4">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">
{title.trim() ?? 'Error'}
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Could not apply changes to the ACL policy:
<br />
<span className="font-mono">{formattedMessage}</span>
</Card.Text>
</Card>
);
}
+1 -1
View File
@@ -23,12 +23,12 @@ export default function Fallback({ acl }: Props) {
/>
</div>
<textarea
readOnly
className={cn(
'w-full h-editor font-mono resize-none text-sm',
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
'pl-1 pt-1 leading-snug',
)}
readOnly
value={acl}
/>
</div>
+33 -80
View File
@@ -1,31 +1,35 @@
import {
AlertCircle,
Construction,
Eye,
FlaskConical,
Pencil,
} from 'lucide-react';
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import { isRouteErrorResponse, useFetcher, useRevalidator } from 'react-router';
import {
ActionFunctionArgs,
LoaderFunctionArgs,
useFetcher,
useLoaderData,
useRevalidator,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import { isApiError } from '~/server/headscale/api/error-client';
import type { LoadContext } from '~/server';
import toast from '~/utils/toast';
import type { Route } from './+types/overview';
import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client';
import { ErrorView, NoticeView } from './components/error';
export const loader = aclLoader;
export const action = aclAction;
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
return aclLoader(request);
}
export default function Page({
loaderData: { access, writable, policy },
}: Route.ComponentProps) {
export async function action(request: ActionFunctionArgs<LoadContext>) {
return aclAction(request);
}
export default function Page() {
// Access is a write check here, we already check read in aclLoader
const { access, writable, policy } = useLoaderData<typeof loader>();
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
@@ -53,49 +57,43 @@ export default function Page({
return (
<div>
{!access ? (
<Notice title="ACL Policy restricted" variant="warning">
<NoticeView title="ACL Policy restricted">
You do not have the necessary permissions to edit the Access Control
List policy. Please contact your administrator to request access or to
make changes to the ACL policy.
</Notice>
</NoticeView>
) : !writable ? (
<Notice title="Read-only ACL Policy" variant="error">
<NoticeView title="Read-only ACL Policy">
The ACL policy mode is most likely set to <Code>file</Code> in your
Headscale configuration. This means that the ACL file cannot be edited
through the web interface. In order to resolve this, you'll need to
set <Code>policy.mode</Code> to <Code>database</Code> in your
Headscale configuration.
</Notice>
set <Code>acl.mode</Code> to <Code>database</Code> in your Headscale
configuration.
</NoticeView>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
name="Tailscale ACL documentation"
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
name="Headscale ACL documentation"
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.error !== undefined ? (
<Notice
title={fetcher.data.error.split(':')[0] ?? 'Error'}
variant="error"
>
{fetcher.data.error.split(':').slice(1).join(': ') ??
'An unknown error occurred while trying to update the ACL policy.'}
</Notice>
<ErrorView>{fetcher.data.error}</ErrorView>
) : undefined}
<Tabs className="mb-4" label="ACL Editor">
<Tabs label="ACL Editor" className="mb-4">
<Tabs.Item
key="edit"
title={
@@ -107,8 +105,8 @@ export default function Page({
>
<Editor
isDisabled={disabled}
onChange={setCodePolicy}
value={codePolicy}
onChange={setCodePolicy}
/>
</Tabs.Item>
<Tabs.Item
@@ -142,6 +140,7 @@ export default function Page({
</Tabs.Item>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={
disabled ||
@@ -151,10 +150,10 @@ export default function Page({
}
onPress={() => {
const formData = new FormData();
console.log(codePolicy);
formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' });
}}
variant="heavy"
>
Save
</Button>
@@ -172,49 +171,3 @@ export default function Page({
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (
isRouteErrorResponse(error) &&
isApiError(error.data) &&
error.data.rawData.includes('reading policy from path') &&
error.data.rawData.includes('no such file or directory')
) {
return (
<div className="flex flex-col gap-4">
<Card className="max-w-2xl" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>ACL Policy Unavailable</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
<Card.Text>
The ACL policy is currently unavailable because the policy file does
not exist on the server. This usually indicates that Headscale is
running in <Code>file</Code> mode for ACLs, and the specified policy
file is missing.
</Card.Text>
</Card>
<Card className="max-w-2xl" variant="flat">
<Card.Text>
In order to resolve this issue, there are two possible actions you
can take:
</Card.Text>
<ul className="list-disc list-outside mt-2 ml-4 space-y-1 text-sm">
<li>
Create the ACL policy file at the specified path in your Headscale
configuration.
</li>
<li>
Alternatively, you can switch Headscale to use{' '}
<Code>database</Code> mode for ACLs by updating your Headscale
configuration. This will allow Headplane to manage the ACL policy
directly through the web interface.
</li>
</ul>
</Card>
</div>
);
}
throw error;
}
+186
View File
@@ -0,0 +1,186 @@
import { useEffect } from 'react';
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
redirect,
useSearchParams,
} from 'react-router';
import { Form, useActionData, useLoaderData } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import type { LoadContext } from '~/server';
import type { Key } from '~/types';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const qp = new URL(request.url).searchParams;
const state = qp.get('s');
try {
const session = await context.sessions.auth(request);
if (session.has('api_key')) {
return redirect('/machines');
}
} catch {}
const disableApiKeyLogin = context.config.oidc?.disable_api_key_login;
if (context.oidc && disableApiKeyLogin) {
// Prevents automatic redirect loop if OIDC is enabled and API key login is disabled
// Since logging out would just log back in based on the redirects
if (state !== 'logout') {
return redirect('/oidc/start');
}
}
return {
oidc: context.oidc,
disableApiKeyLogin,
state,
};
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const formData = await request.formData();
const oidcStart = formData.get('oidc-start');
const session = await context.sessions.getOrCreate(request);
if (oidcStart) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
return redirect('/oidc/start');
}
const apiKey = String(formData.get('api-key'));
// Test the API key
try {
const apiKeys = await context.client.get<{ apiKeys: Key[] }>(
'v1/apikey',
apiKey,
);
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
if (!key) {
return {
error: 'Invalid API key',
};
}
const expiry = new Date(key.expiration);
const expiresIn = expiry.getTime() - Date.now();
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
session.set('state', 'auth');
session.set('api_key', apiKey);
session.set('user', {
subject: 'unknown-non-oauth',
name: key.prefix,
email: `${expiresDays.toString()} days`,
});
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.commit(session, {
maxAge: expiresIn,
}),
},
});
} catch {
return {
error: 'Invalid API key',
};
}
}
export default function Page() {
const { state, disableApiKeyLogin, oidc } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [params] = useSearchParams();
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (state !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete('s');
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
window.history.replaceState(null, '', newUrl);
}
}, [state, params]);
if (state === 'logout') {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>You have been logged out</Card.Title>
<Card.Text>
You can now close this window. If you would like to log in again,
please refresh the page.
</Card.Text>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>Welcome to Headplane</Card.Title>
{!disableApiKeyLogin ? (
<Form method="post">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
{actionData?.error ? (
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
) : undefined}
<Input
isRequired
labelHidden
label="API Key"
name="api-key"
placeholder="API Key"
type="password"
className="mt-4 mb-2"
/>
<Button className="w-full" variant="heavy" type="submit">
Sign In
</Button>
</Form>
) : undefined}
{oidc ? (
<Form method="POST">
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full mt-2"
variant={disableApiKeyLogin ? 'heavy' : 'light'}
type="submit"
>
Single Sign-On
</Button>
</Form>
) : undefined}
</Card>
</div>
);
}
-102
View File
@@ -1,102 +0,0 @@
import { data, redirect } from "react-router";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import log from "~/utils/log";
import type { Route } from "./+types/page";
export async function loginAction({ request, context }: Route.LoaderArgs) {
const formData = await request.formData();
const apiKey = formData.has("api_key") ? String(formData.get("api_key")) : undefined;
if (apiKey === undefined) {
log.warn("auth", "Request made without API key");
log.warn(
"auth",
"If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly",
);
throw data("Missing `api_key`", { status: 400 });
}
if (apiKey.length === 0) {
log.warn("auth", "Request made with empty API key");
log.warn(
"auth",
"If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly",
);
throw data("Received an empty `api_key`", { status: 400 });
}
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const apiKeys = await api.getApiKeys();
console.log(apiKeys);
// We don't need to check for 0 API keys because this request cannot
// be authenticated correctly without an API key
//
// 0.28.0 pointlessly added asterisks to the prefixes of API keys, which is
// the dumbest thing I've ever seen.
const lookup = apiKeys.find((key) => apiKey.startsWith(key.prefix.replaceAll("*", "")));
if (!lookup) {
return {
success: false,
message: "API key was not found in the Headscale database",
};
}
if (lookup.expiration === null || lookup.expiration === undefined) {
log.error("auth", "Got an API key without an expiration");
throw data("API key is malformed", { status: 500 });
}
const expiry = new Date(lookup.expiration);
if (expiry.getTime() < Date.now()) {
return {
success: false,
message: "API key has expired",
};
}
const expiresDays = Math.round((expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24);
return redirect("/machines", {
headers: {
"Set-Cookie": await context.sessions.createSession(
{
api_key: apiKey,
user: {
subject: "unknown-non-oauth",
name: `${lookup.prefix}...`,
email: `expires@${expiresDays.toString()}-days`,
},
},
expiry.getTime() - Date.now(),
),
},
});
} catch (error) {
// Check if this is a React Router DataWithResponseInit wrapping a Headscale API error
if (isDataWithApiError(error)) {
const apiError = error.data;
// TODO: What in gods name is wrong with the headscale API?
if (
apiError.statusCode === 401 ||
apiError.statusCode === 403 ||
(apiError.statusCode === 500 && apiError.rawData.trim() === "Unauthorized")
) {
return {
success: false,
message: "API key is invalid (it may be incorrect or expired)",
};
}
}
log.error("auth", "Error while validating API key: %s", error);
log.debug("auth", "Error details: %o", error);
return {
success: false,
message: "Error while validating API key (see logs for details)",
};
}
}
-131
View File
@@ -1,131 +0,0 @@
import { AlertCircle, CloudOff } from "lucide-react";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Link from "~/components/Link";
import { OidcConnectorError } from "~/server/web/oidc-connector";
export function OidcDiscoveryFailedNotice() {
return (
<Card className="m-4 mb-4 max-w-md border border-yellow-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-yellow-500">SSO Temporarily Unavailable</Card.Title>
<CloudOff className="mb-2 h-6 w-6 text-yellow-500" />
</div>
<Card.Text className="text-sm">
Unable to reach the identity provider. Single Sign-On will be available once the provider is
reachable again. You can still sign in with an API key.
</Card.Text>
</Card>
);
}
export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[] }) {
return (
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Authentication Error</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text className="text-sm">
The OpenID Connect (OIDC) Single Sign-On (SSO) configuration has issues:{" "}
<ul className="mt-2 mb-1 list-inside list-disc">
{mapOidcErrorsToMessages(errors).map((code) => (
<li key={code.key}>{code.node}</li>
))}
</ul>{" "}
<Link
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#troubleshooting"
>
Learn more
</Link>
</Card.Text>
</Card>
);
}
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
const messages: {
key: string;
node: React.ReactNode;
}[] = [];
for (const error of errors) {
switch (error) {
case "INVALID_API_KEY":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The provided API key for OIDC authentication is invalid. Ensure that{" "}
<Code>oidc.headscale_api_key</Code> is a valid API key.
</Card.Text>
),
});
break;
case "MISSING_AUTHORIZATION_ENDPOINT":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>authorization_endpoint</Code>.
Ensure discovery URL or manual configuration is correct.
</Card.Text>
),
});
break;
case "MISSING_TOKEN_ENDPOINT":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>token_endpoint</Code>. Ensure
discovery URL or manual configuration is correct.
</Card.Text>
),
});
break;
case "MISSING_USERINFO_ENDPOINT":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>user_endpoint</Code>. Ensure
discovery URL or manual configuration is correct.
</Card.Text>
),
});
break;
case "MISSING_REQUIRED_CLAIMS":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provider does not support the <Code>sub</Code> claim, which is required for
authentication. Your OIDC provider may be misconfigured.
</Card.Text>
),
});
break;
case "UNKNOWN_ERROR":
messages.push({
key: error,
node: (
<Card.Text className="inline">
An unknown error occurred during OIDC configuration. Please check the Headplane logs
for more information.
</Card.Text>
),
});
break;
}
}
return messages;
}
-15
View File
@@ -1,15 +0,0 @@
import Card from '~/components/Card';
export default function Logout() {
return (
<div className="flex w-screen h-screen items-center justify-center">
<Card className="max-w-md m-4 sm:m-0">
<Card.Title>You have been logged out</Card.Title>
<Card.Text>
You can now close this window. If you would like to log in again,
please refresh the page.
</Card.Text>
</Card>
</div>
);
}
-63
View File
@@ -1,63 +0,0 @@
import { AlertCircle } from 'lucide-react';
import Card from '~/components/Card';
import Code from '~/components/Code';
export function OidcErrorNotice({ code }: { code: string }) {
return (
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Configuration Issue(s)</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{getErrorMessage(code)}
</Card>
);
}
function getErrorMessage(code: string) {
switch (code) {
case 'error_no_query':
return (
<Card.Text>
The SSO provider did not correctly redirect back to Headplane with the
required parameters. Please ensure your SSO provider is configured
correctly.
</Card.Text>
);
case 'error_no_session':
case 'error_invalid_session':
return (
<Card.Text>
Unable to complete SSO login due to missing or invalid session data.
Ensure that your Headplane cookie configuration is correct and that
your browser is accepting cookies.
</Card.Text>
);
case 'error_no_sub':
return (
<Card.Text>
The SSO provider did not return a valid user identifier. Please ensure
your SSO provider is correctly configured to provide the{' '}
<Code>sub</Code> claim.
</Card.Text>
);
case 'error_auth_failed':
return (
<Card.Text>
Authentication with the SSO provider failed. Please try again later.
Headplane logs may provide more information.
</Card.Text>
);
default:
return (
<Card.Text>
An unknown error occurred during OIDC authentication. Please try again
later.
</Card.Text>
);
}
}
-156
View File
@@ -1,156 +0,0 @@
import { AlertCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { Form, Link as RemixLink, redirect, useSearchParams } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Input from "~/components/Input";
import Link from "~/components/Link";
import { useLiveData } from "~/utils/live-data";
import type { Route } from "./+types/page";
import { loginAction } from "./action";
import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error";
import Logout from "./logout";
import { OidcErrorNotice } from "./oidc-error";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
return redirect("/machines");
} catch {}
const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined;
const oidcConnector = await context.oidcConnector?.get();
// MARK: This works because the OIDC connector will always return false
// for `isExclusive` if the OIDC config isn't usable.
if (oidcConnector?.isExclusive && urlState !== "logout") {
return redirect("/oidc/start");
}
const isOidcConnectorEnabled = oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled ? (oidcConnector?.errors ?? []) : [];
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
};
}
export const action = loginAction;
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
const { isCookieSecureEnabled, isOidcConnectorEnabled, oidcErrorCodes, urlState } = loaderData;
const [showCookieWarning, setShowCookieWarning] = useState(false);
const [params] = useSearchParams();
const { pause } = useLiveData();
useEffect(() => {
// This page does NOT need stale while revalidate logic
pause();
if (isCookieSecureEnabled && window.location.protocol !== "https:") {
setShowCookieWarning(true);
}
});
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (urlState !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete("s");
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
window.history.replaceState(null, "", newUrl);
}
}, [urlState, params]);
if (urlState === "logout") {
return <Logout />;
}
return (
<div className="flex h-screen w-screen items-center justify-center">
<div>
{urlState?.startsWith("error_") ? (
<OidcErrorNotice code={urlState} />
) : oidcErrorCodes.includes("DISCOVERY_FAILED") ? (
<OidcDiscoveryFailedNotice />
) : oidcErrorCodes.length > 0 ? (
<OidcConfigErrorNotice errors={oidcErrorCodes} />
) : showCookieWarning ? (
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Configuration Issue</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
{showCookieWarning ? (
<Card.Text className="text-sm">
Headplane is configured to use secure cookies, but this site is being served over an
insecure connection and login will not work correctly.{" "}
<Link
name="Headplane Common Issues"
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
>
Learn more.
</Link>
</Card.Text>
) : undefined}
</Card>
) : undefined}
<Card className="m-4 max-w-md sm:m-0">
<Card.Title>Welcome to Headplane</Card.Title>
<Form method="POST">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate one by running{" "}
<Code>headscale apikeys create</Code> in your terminal.
</Card.Text>
<Input
className="mt-8 mb-2"
isRequired
label="API Key"
labelHidden
name="api_key"
placeholder="API Key"
type="password"
/>
{actionData?.success === false ? (
<Card.Text className="mb-2 text-sm text-red-600 dark:text-red-300">
{actionData.message}
</Card.Text>
) : undefined}
<Button className="w-full" type="submit" variant="heavy">
Sign In
</Button>
</Form>
{isOidcConnectorEnabled ? (
<RemixLink to="/oidc/start">
<Button
className="mt-2 w-full"
isDisabled={oidcErrorCodes.length > 0}
variant="light"
>
Single Sign-On
</Button>
</RemixLink>
) : undefined}
</Card>
</div>
</div>
);
}
+4 -5
View File
@@ -9,10 +9,9 @@ export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
try {
await context.sessions.auth(request);
} catch {
redirect('/login');
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
return redirect('/login');
}
// When API key is disabled, we need to explicitly redirect
@@ -23,7 +22,7 @@ export async function action({
return redirect(url, {
headers: {
'Set-Cookie': await context.sessions.destroySession(),
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
+60 -146
View File
@@ -1,155 +1,69 @@
import { count, eq } from "drizzle-orm";
import { createHash } from "node:crypto";
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { ulid } from "ulidx";
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
import { users } from "~/server/db/schema";
import { Roles } from "~/server/web/roles";
import log from "~/utils/log";
import { createOidcStateCookie } from "~/utils/oidc-state";
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
import type { Route } from "./+types/oidc-callback";
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/login');
}
export async function loader({ request, context }: Route.LoaderArgs) {
const oidcConnector = await context.oidcConnector?.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if (session.get('state') !== 'flow') {
return redirect('/login'); // Haven't started an OIDC flow
}
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect("/login?s=error_no_query");
}
const payload = session.get('oidc')!;
const { code_verifier, state, nonce, redirect_uri } = payload;
if (!code_verifier || !state || !nonce || !redirect_uri) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
}
const cookie = createOidcStateCookie(context.config);
const oidcCookieState = await cookie.parse(request.headers.get("Cookie"));
// Reconstruct the redirect URI using the query parameters
// and the one we saved in the session
const flowRedirectUri = new URL(redirect_uri);
flowRedirectUri.search = url.search;
if (oidcCookieState == null) {
log.warn("auth", "Called OIDC callback without session cookie");
return redirect("/login?s=error_no_session");
}
const flowOptions = {
redirect_uri: flowRedirectUri.toString(),
code_verifier,
state,
nonce: nonce === '<none>' ? undefined : nonce,
};
const { state, nonce, redirect_uri, verifier } = oidcCookieState;
if (!state || !nonce || !redirect_uri || !verifier) {
log.warn("auth", "OIDC session cookie is missing required fields");
return redirect("/login?s=error_invalid_session");
}
try {
const user = await finishAuthFlow(context.oidc, flowOptions);
session.unset('oidc');
const userSession = session as Session<AuthSession>;
try {
const callbackUrl = new URL(redirect_uri);
const currentUrl = new URL(request.url);
callbackUrl.search = currentUrl.search;
const tokens = await oidc.authorizationCodeGrant(oidcConnector.client, callbackUrl, {
expectedState: state,
expectedNonce: nonce,
...(oidcConnector.usePKCE ? { pkceCodeVerifier: verifier } : {}),
});
const claims = tokens.claims();
if (claims?.sub == null) {
log.warn("auth", "No subject found in OIDC claims");
return redirect("/login?s=error_no_sub");
}
const userInfo = await oidc.fetchUserInfo(
oidcConnector.client,
tokens.access_token,
claims.sub,
);
// We have defaults that closely follow what Headscale uses, maybe we
// can make it configurable in the future, but for now we only need the
// `sub` claim.
const username = userInfo.preferred_username ?? userInfo.email?.split("@")[0] ?? "user";
const name =
userInfo.name ??
(userInfo.given_name && userInfo.family_name
? `${userInfo.given_name} ${userInfo.family_name}`
: (userInfo.preferred_username ?? "SSO User"));
const picture =
context.config.oidc?.profile_picture_source === "gravatar"
? (() => {
if (!userInfo.email) {
return undefined;
}
const emailHash = userInfo.email.trim().toLowerCase();
const hash = createHash("sha256").update(emailHash).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
})()
: userInfo.picture;
const [{ count: userCount }] = await context.db
.select({ count: count() })
.from(users)
.where(eq(users.caps, Roles.owner));
await context.db
.insert(users)
.values({
id: ulid(),
sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
return redirect("/", {
headers: {
"Set-Cookie": await context.sessions.createSession({
api_key: oidcConnector.apiKey,
user: {
subject: claims.sub,
username,
name,
email: userInfo.email,
picture,
},
}),
},
});
} catch (error) {
if (error instanceof oidc.ResponseBodyError) {
log.error("auth", "Got an OIDC response error body: %s", JSON.stringify(error.cause));
// Check for PKCE-related errors
if (
error.error.toLowerCase().includes("code_verifier") ||
error.error.toLowerCase().includes("code verifier") ||
error.error.toLowerCase().includes("pkce")
) {
log.error(
"auth",
"PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s",
oidcConnector.usePKCE,
);
if (!oidcConnector.usePKCE) {
log.error(
"auth",
"Consider setting oidc.use_pkce=true in your configuration if your provider requires PKCE",
);
}
}
} else if (error instanceof oidc.AuthorizationResponseError) {
log.error("auth", "Got an OIDC authorization response error: %s", error.error);
} else if (error instanceof oidc.WWWAuthenticateChallengeError) {
log.error("auth", "Got an OIDC WWW-Authenticate challenge error");
} else if (error instanceof oidc.ClientError) {
log.error(
"auth",
"Got an OIDC authorization client error: %s",
error.cause instanceof Error ? error.cause.message : String(error.cause),
);
} else {
log.error(
"auth",
"Got an OIDC error: %s",
error instanceof Error && error.cause ? JSON.stringify(error.cause) : String(error),
);
}
return redirect("/login?s=error_auth_failed");
}
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
userSession.set('user', user);
userSession.set('api_key', context.config.oidc?.headscale_api_key!);
userSession.set('state', 'auth');
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.commit(userSession),
},
});
} catch (error) {
return new Response(JSON.stringify(formatError(error)), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}
+36 -75
View File
@@ -1,81 +1,42 @@
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
import { HeadplaneConfig } from "~/server/config/config-schema";
import { createOidcStateCookie } from "~/utils/oidc-state";
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if ((session as Session<AuthSession>).has('api_key')) {
return redirect('/machines');
}
import type { Route } from "./+types/oidc-start";
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
return redirect("/");
} catch {}
const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(
context.oidc,
redirectUri,
// We can't get here without the OIDC config being defined
context.config.oidc!.token_endpoint_auth_method,
);
const oidcConnector = await context.oidcConnector?.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
session.set('state', 'flow');
session.set('oidc', {
state: data.state,
nonce: data.nonce,
code_verifier: data.codeVerifier,
redirect_uri: redirectUri,
});
const cookie = createOidcStateCookie(context.config);
const redirect_uri = getRedirectUri(context.config, request);
const nonce = oidc.randomNonce();
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(oidcConnector.client, {
...oidcConnector.extraParams,
scope: oidcConnector.scope,
redirect_uri,
state,
nonce,
...(oidcConnector.usePKCE
? {
code_challenge_method: "S256",
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
}
: {}),
});
return redirect(url.href, {
status: 302,
headers: {
"Set-Cookie": await cookie.serialize({
state,
nonce,
verifier,
redirect_uri,
}),
},
});
}
function getRedirectUri(config: HeadplaneConfig, req: Request): string {
if (config.server.base_url != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.server.base_url);
return url.href;
}
if (config.oidc?.redirect_uri != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.oidc.redirect_uri);
return url.href;
}
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
let host = req.headers.get("Host");
if (!host) {
host = req.headers.get("X-Forwarded-Host");
}
if (!host) {
throw data("Cannot determine redirect URI: no Host or X-Forwarded-Host header", {
status: 500,
});
}
const proto = req.headers.get("X-Forwarded-Proto");
url.protocol = proto ?? "http:";
url.host = host;
return url.href;
return redirect(data.url, {
status: 302,
headers: {
'Set-Cookie': await context.sessions.commit(session),
},
});
}
-119
View File
@@ -1,119 +0,0 @@
import { eq } from "drizzle-orm";
import { ClockIcon, LogOut, RefreshCw, UserCheck } from "lucide-react";
import { Form, redirect } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import { users } from "~/server/db/schema";
import { Capabilities } from "~/server/web/roles";
import toast from "~/utils/toast";
import type { Route } from "./+types/pending-approval";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const session = await context.sessions.auth(request);
// API key users skip this page
if (session.user.subject === "unknown-non-oauth") {
return redirect("/machines");
}
const hasAccess = await context.sessions.check(request, Capabilities.ui_access);
if (hasAccess) {
return redirect("/machines");
}
const [user] = await context.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
const url = context.config.headscale.public_url ?? context.config.headscale.url;
return {
user: session.user,
url,
exists: !!user,
};
} catch {
return redirect("/login", {
headers: {
"Set-Cookie": await context.sessions.destroySession(),
},
});
}
}
export default function PendingApproval({ loaderData }: Route.ComponentProps) {
return (
<div className="flex h-screen w-screen items-center justify-center">
<Card className="m-4 max-w-md sm:m-0">
<div className="mb-4 flex items-center gap-3">
<div className="rounded-full bg-amber-100 p-3 dark:bg-amber-900">
<ClockIcon className="h-8 w-8 text-amber-600 dark:text-amber-400" />
</div>
<div>
<Card.Title className="mb-0 text-xl">Approval Required</Card.Title>
<p className="text-headplane-500 text-sm">
{loaderData.user.email ?? loaderData.user.name}
</p>
</div>
</div>
<Card.Text className="mb-4">
Your account has been created but requires approval from an administrator before you can
access the management console.
</Card.Text>
<div className="bg-headplane-50 dark:bg-headplane-900 mb-4 rounded-lg p-4">
<div className="mb-2 flex items-center gap-2">
<UserCheck className="text-headplane-500 h-5 w-5" />
<p className="font-medium">What happens next?</p>
</div>
<ul className="text-headplane-600 dark:text-headplane-400 list-inside list-disc space-y-1 text-sm">
<li>An administrator will review your account</li>
<li>Once approved, you will receive the appropriate access level</li>
<li>This page will automatically redirect you once approved</li>
</ul>
</div>
<Card.Text className="mb-4 text-sm">
In the meantime, you can still connect your devices to the Tailnet using the command
below:
</Card.Text>
<Button
className="w-full font-mono text-sm"
variant="light"
onPress={async () => {
await navigator.clipboard.writeText(`tailscale up --login-server=${loaderData.url}`);
toast("Copied to clipboard");
}}
>
tailscale up --login-server={loaderData.url}
</Button>
<p className="mt-1 text-center text-xs opacity-50">Click to copy the command</p>
<div className="bg-headplane-100 dark:bg-headplane-800 mb-4 flex items-center justify-center gap-2 rounded-lg p-3 text-sm">
<RefreshCw className="text-headplane-500 h-4 w-4 animate-spin" />
<span className="text-headplane-600 dark:text-headplane-400">
Checking for approval automatically...
</span>
</div>
<Form action="/logout" method="post">
<Button
type="submit"
variant="heavy"
className="flex w-full items-center justify-center gap-2"
>
<LogOut className="h-4 w-4" />
Sign Out
</Button>
</Form>
</Card>
</div>
);
}
+25 -25
View File
@@ -1,18 +1,18 @@
import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
arrayMove,
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Lock } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Form } from 'react-router';
import { type FetcherWithComponents, Form, useFetcher } from 'react-router';
import Button from '~/components/Button';
import Input from '~/components/Input';
import TableList from '~/components/TableList';
@@ -37,15 +37,18 @@ export default function ManageDomains({
}, [searchDomains]);
return (
<div className="flex flex-col w-full sm:w-2/3">
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className="mb-4">
Set custom DNS search domains for your Tailnet. When using Magic DNS,
your tailnet domain is used as the first search domain.
</p>
<DndContext
collisionDetection={closestCorners}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
onDragEnd={(event) => {
setActiveId(null);
const { active, over } = event;
@@ -67,9 +70,6 @@ export default function ManageDomains({
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
>
<TableList>
{magic ? (
@@ -91,19 +91,19 @@ export default function ManageDomains({
>
{localDomains.map((sd, index) => (
<Domain
key={sd}
domain={sd}
id={index + 1}
isDisabled={isDisabled}
key={sd}
/>
))}
<DragOverlay adjustScale>
{activeId ? (
<Domain
isDragging
domain={localDomains[(activeId as number) - 1]}
id={(activeId as number) - 1}
isDisabled={isDisabled}
isDragging
/>
) : undefined}
</DragOverlay>
@@ -111,28 +111,28 @@ export default function ManageDomains({
{isDisabled ? undefined : (
<TableList.Item key="add-sd">
<Form
className="flex items-center justify-between w-full"
method="POST"
className="flex items-center justify-between w-full"
>
<input name="action_id" type="hidden" value="add_domain" />
<input type="hidden" name="action_id" value="add_domain" />
<Input
type="text"
className={cn(
'border-none font-mono p-0 text-sm',
'rounded-none focus:ring-0 w-full ml-1',
)}
isRequired
label="Search Domain"
labelHidden
name="domain"
placeholder="Search Domain"
type="text"
label="Search Domain"
name="domain"
labelHidden
isRequired
/>
<Button
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-blue-500 dark:text-blue-400',
)}
type="submit"
>
Add
</Button>
@@ -164,11 +164,11 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
return (
<TableList.Item
ref={setNodeRef}
className={cn(
isSortableDragging ? 'opacity-50' : '',
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
isDragging ? 'ring bg-white dark:bg-headplane-900' : '',
)}
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
@@ -179,22 +179,22 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
<GripVertical
{...attributes}
{...listeners}
className="p-0.5 focus:ring-3 outline-hidden rounded-md"
className="p-0.5 focus:ring outline-none rounded-md"
/>
)}
{domain}
</p>
{isDragging ? undefined : (
<Form method="POST">
<input name="action_id" type="hidden" value="remove_domain" />
<input name="domain" type="hidden" value={domain} />
<input type="hidden" name="action_id" value="remove_domain" />
<input type="hidden" name="domain" value={domain} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
+15 -69
View File
@@ -1,33 +1,25 @@
import { Info } from 'lucide-react';
import { Form, useSubmit } from 'react-router';
import { Form } from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import Switch from '~/components/Switch';
import TableList from '~/components/TableList';
import Tooltip from '~/components/Tooltip';
import cn from '~/utils/cn';
import AddNS from '../dialogs/add-ns';
interface Props {
nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
isDisabled: boolean;
}
export default function ManageNS({
nameservers,
isDisabled,
overrideLocalDns,
}: Props) {
export default function ManageNS({ nameservers, isDisabled }: Props) {
return (
<div className="flex flex-col w-full sm:w-2/3">
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p>
Set the nameservers used by devices on the Tailnet to resolve DNS
queries.{' '}
<Link
name="Tailscale DNS Documentation"
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation"
>
Learn more
</Link>
@@ -35,12 +27,11 @@ export default function ManageNS({
<div className="mt-4">
{Object.keys(nameservers).map((key) => (
<NameserverList
isDisabled={isDisabled}
isGlobal={key === 'global'}
key={key}
name={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers}
overrideLocalDns={overrideLocalDns}
name={key}
/>
))}
@@ -54,7 +45,6 @@ interface ListProps {
isGlobal: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
name: string;
}
@@ -62,12 +52,9 @@ function NameserverList({
isGlobal,
isDisabled,
nameservers,
overrideLocalDns,
name,
}: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name];
const submit = useSubmit();
if (list.length === 0) {
return null;
}
@@ -75,50 +62,9 @@ function NameserverList({
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
{isGlobal ? (
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-2">
<h2 className="text-md font-medium opacity-80">
Global Nameservers
</h2>
<div className="flex items-center gap-2 text-sm">
<Tooltip>
<Info className="size-4" />
<Tooltip.Body>
When enabled, use the DNS servers listed below to resolve
names outside the tailnet. When disabled (default), devices
will prefer their local DNS configuration.
<Link
name="Tailscale Global Nameservers Documentation"
to="https://tailscale.com/kb/1054/dns#global-nameservers"
>
Learn More
</Link>
</Tooltip.Body>
</Tooltip>
<p>Override DNS servers</p>
<Switch
className="h-[15px] w-[23px] p-0.5"
defaultSelected={overrideLocalDns}
label="Override local DNS settings"
name="override_dns"
onChange={(v) => {
submit(
{
action_id: 'override_dns',
override_dns: v ? 'true' : 'false',
},
{
method: 'POST',
},
);
}}
switchClassName="h-[9px] w-[9px]"
/>
</div>
</div>
) : (
<h2 className="text-md font-medium opacity-80">{name}</h2>
)}
<h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name}
</h2>
</div>
<TableList>
{list.length > 0
@@ -126,20 +72,20 @@ function NameserverList({
<TableList.Item key={ns}>
<p className="font-mono text-sm">{ns}</p>
<Form method="POST">
<input name="action_id" type="hidden" value="remove_ns" />
<input name="ns" type="hidden" value={ns} />
<input type="hidden" name="action_id" value="remove_ns" />
<input type="hidden" name="ns" value={ns} />
<input
name="split_name"
type="hidden"
name="split_name"
value={isGlobal ? 'global' : name}
/>
<Button
isDisabled={isDisabled}
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
+20 -22
View File
@@ -13,14 +13,14 @@ interface Props {
export default function ManageRecords({ records, isDisabled }: Props) {
return (
<div className="flex flex-col w-full sm:w-2/3">
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p>
Headscale supports adding custom DNS records to your Tailnet. As of now,
only <Code>A</Code> and <Code>AAAA</Code> records are supported.{' '}
only <Code>A</Code> records are supported.{' '}
<Link
name="Headscale DNS Records documentation"
to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation"
>
Learn More
</Link>
@@ -32,35 +32,33 @@ export default function ManageRecords({ records, isDisabled }: Props) {
<p className="opacity-50 mx-auto">No DNS records found</p>
</TableList.Item>
) : (
records.map((record) => (
records.map((record, index) => (
<TableList.Item key={`${record.name}-${record.value}`}>
<div className="flex gap-2 items-center w-full">
<p
className={cn(
'font-mono text-sm font-bold py-1 px-2 rounded-md text-center',
'bg-headplane-100 dark:bg-headplane-700/30 min-w-12',
)}
>
{record.type}
</p>
<div className="flex flex-col sm:flex-row sm:gap-2 flex-1 min-w-0">
<p className="font-mono text-sm truncate">{record.name}</p>
<p className="font-mono text-sm truncate opacity-70 sm:opacity-100">
{record.value}
<div className="flex gap-24 items-center">
<div className="flex gap-4 items-center">
<p
className={cn(
'font-mono text-sm font-bold py-1 px-2 rounded-md',
'bg-headplane-100 dark:bg-headplane-700/30',
)}
>
{record.type}
</p>
<p className="font-mono text-sm">{record.name}</p>
</div>
<p className="font-mono text-sm">{record.value}</p>
</div>
<Form method="POST">
<input name="action_id" type="hidden" value="remove_record" />
<input name="record_name" type="hidden" value={record.name} />
<input name="record_type" type="hidden" value={record.type} />
<input type="hidden" name="action_id" value="remove_record" />
<input type="hidden" name="record_name" value={record.name} />
<input type="hidden" name="record_type" value={record.type} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
+7 -7
View File
@@ -9,21 +9,21 @@ interface Props {
export default function RenameTailnet({ name, isDisabled }: Props) {
return (
<div className="flex flex-col w-full sm:w-2/3 gap-y-4">
<div className="flex flex-col w-2/3 gap-y-4">
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
<p>
This is the base domain name of your Tailnet. Devices are accessible at{' '}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
className="w-3/5 font-medium text-sm"
isReadOnly
label="Tailnet name"
labelHidden
className="w-3/5 font-medium text-sm"
label="Tailnet name"
value={name}
onFocus={(event) => {
event.target.select();
}}
value={name}
/>
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
@@ -33,13 +33,13 @@ export default function RenameTailnet({ name, isDisabled }: Props) {
Keep in mind that changing this can lead to all sorts of unexpected
behavior and may break existing devices in your tailnet.
</Dialog.Text>
<input name="action_id" type="hidden" value="rename_tailnet" />
<input type="hidden" name="action_id" value="rename_tailnet" />
<Input
defaultValue={name}
isRequired
label="Tailnet name"
name="new_name"
placeholder="ts.net"
defaultValue={name}
name="new_name"
/>
</Dialog.Panel>
</Dialog>
+9 -9
View File
@@ -1,4 +1,4 @@
import { Split } from 'lucide-react';
import { RepoForkedIcon } from '@primer/octicons-react';
import { useMemo, useState } from 'react';
import Chip from '~/components/Chip';
import Dialog from '~/components/Dialog';
@@ -35,15 +35,15 @@ export default function AddNameserver({ nameservers }: Props) {
<Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel>
<Dialog.Title className="mb-4">Add nameserver</Dialog.Title>
<input name="action_id" type="hidden" value="add_ns" />
<input type="hidden" name="action_id" value="add_ns" />
<Input
description="Use this IPv4 or IPv6 address to resolve names."
isInvalid={isInvalid}
isRequired
label="Nameserver"
description="Use this IPv4 or IPv6 address to resolve names."
placeholder="1.2.3.4"
name="ns"
onChange={setNs}
placeholder="1.2.3.4"
isInvalid={isInvalid}
/>
<div className="flex items-center justify-between mt-8">
<div className="block">
@@ -53,9 +53,9 @@ export default function AddNameserver({ nameservers }: Props) {
</Dialog.Text>
<Tooltip>
<Chip
className={cn('inline-flex items-center')}
leftIcon={<Split className="w-3 h-3 mr-0.5" />}
text="Split DNS"
leftIcon={<RepoForkedIcon className="w-4 h-4 mr-0.5" />}
className={cn('inline-flex items-center')}
/>
<Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or later
@@ -76,9 +76,9 @@ export default function AddNameserver({ nameservers }: Props) {
<Input
isRequired={split === true}
label="Domain"
placeholder="example.com"
name="split_name"
onChange={setDomain}
placeholder="example.com"
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this suffix
@@ -86,7 +86,7 @@ export default function AddNameserver({ nameservers }: Props) {
</Dialog.Text>
</>
) : (
<input name="split_name" type="hidden" value="global" />
<input type="hidden" name="split_name" value="global" />
)}
</Dialog.Panel>
</Dialog>
+4 -24
View File
@@ -2,14 +2,12 @@ import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Select from '~/components/Select';
interface Props {
records: { name: string; type: 'A' | 'AAAA' | string; value: string }[];
records: { name: string; type: 'A' | string; value: string }[];
}
export default function AddRecord({ records }: Props) {
const [type, setType] = useState<'A' | 'AAAA' | string>('A');
const [name, setName] = useState('');
const [ip, setIp] = useState('');
@@ -24,30 +22,14 @@ export default function AddRecord({ records }: Props) {
return (
<Dialog>
<Dialog.Button>Add DNS record</Dialog.Button>
<Dialog.Panel
onSubmit={() => {
setName('');
setIp('');
}}
>
<Dialog.Panel>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<div className="flex flex-col gap-2 mt-4">
<input type="hidden" name="action_id" value="add_record" />
<Select
isRequired
label="Record Type"
name="record_type"
defaultInputValue={type}
onSelectionChange={(v) => {
if (v) setType(v.toString() as 'A' | 'AAAA');
}}
>
<Select.Item key="A">A</Select.Item>
<Select.Item key="AAAA">AAAA</Select.Item>
</Select>
<input type="hidden" name="record_type" value="A" />
<Input
isRequired
label="Domain"
@@ -59,9 +41,7 @@ export default function AddRecord({ records }: Props) {
<Input
isRequired
label="IP Address"
placeholder={
type === 'AAAA' ? '2001:db8::ff00:42:8329' : '101.101.101.101'
}
placeholder="101.101.101.101"
name="record_value"
onChange={setIp}
isInvalid={isDuplicate}
+206 -205
View File
@@ -1,8 +1,11 @@
import { data } from 'react-router';
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
export async function dnsAction({ request, context }: Route.ActionArgs) {
export async function dnsAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const check = await context.sessions.check(
request,
Capabilities.write_network,
@@ -16,9 +19,6 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
return data({ success: false }, 403);
}
// We only need it for health checks which don't require auth
const api = context.hsApi.getRuntimeClient('fake-api-key');
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
@@ -26,206 +26,207 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
}
switch (action) {
case 'rename_tailnet': {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.base_domain',
value: newName,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Tailnet renamed successfully' };
}
case 'toggle_magic': {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Magic DNS state updated successfully' };
}
case 'remove_ns': {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers.length > 0 ? servers : null,
},
]);
}
await context.integration?.onConfigChange(api);
return { message: 'Nameserver removed successfully' };
}
case 'add_ns': {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await context.integration?.onConfigChange(api);
return { message: 'Nameserver added successfully' };
}
case 'remove_domain': {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Domain removed successfully' };
}
case 'add_domain': {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains;
domains.push(domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Domain added successfully' };
}
case 'remove_record': {
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
// Value is not needed for removal
const restart = await context.hs.removeDNS({
name: recordName,
type: recordType,
value: '',
});
if (!restart) {
return;
}
await context.integration?.onConfigChange(api);
return { message: 'DNS record removed successfully' };
}
case 'add_record': {
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
const restart = await context.hs.addDNS({
name: recordName,
type: recordType,
value: recordValue,
});
if (!restart) {
return;
}
await context.integration?.onConfigChange(api);
return { message: 'DNS record added successfully' };
}
case 'override_dns': {
const override = formData.get('override_dns')?.toString();
if (!override) {
return data({ success: false }, 400);
}
const overrideValue = override === 'true';
await context.hs.patch([
{
path: 'dns.override_local_dns',
value: overrideValue,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'DNS override updated successfully' };
}
case 'rename_tailnet':
return renameTailnet(formData, context);
case 'toggle_magic':
return toggleMagic(formData, context);
case 'remove_ns':
return removeNs(formData, context);
case 'add_ns':
return addNs(formData, context);
case 'remove_domain':
return removeDomain(formData, context);
case 'add_domain':
return addDomain(formData, context);
case 'remove_record':
return removeRecord(formData, context);
case 'add_record':
return addRecord(formData, context);
default:
return data({ success: false }, 400);
}
}
async function renameTailnet(formData: FormData, context: LoadContext) {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.base_domain',
value: newName,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function toggleMagic(formData: FormData, context: LoadContext) {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
await context.integration?.onConfigChange(context.client);
}
async function removeNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await context.integration?.onConfigChange(context.client);
}
async function addNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await context.integration?.onConfigChange(context.client);
}
async function removeDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function addDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains;
domains.push(domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function removeRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
const records = config.dns.extra_records.filter(
(i) => i.name !== recordName || i.type !== recordType,
);
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function addRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
const records = config.dns.extra_records;
records.push({ name: recordName, type: recordType, value: recordValue });
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
},
]);
await context.integration?.onConfigChange(context.client);
}
+8 -13
View File
@@ -44,8 +44,7 @@ export async function loader({
nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
overrideDns: config.dns.override_local_dns,
extraRecords: context.hs.d,
extraRecords: config.dns.extra_records,
};
return {
@@ -71,7 +70,7 @@ export default function Page() {
const isDisabled = data.access === false || data.writable === false;
return (
<div className="flex flex-col gap-16 max-w-(--breakpoint-lg)">
<div className="flex flex-col gap-16 max-w-screen-lg">
{data.writable ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to
@@ -84,20 +83,16 @@ export default function Page() {
tailnet.
</Notice>
)}
<RenameTailnet isDisabled={isDisabled} name={data.baseDomain} />
<ManageNS
isDisabled={isDisabled}
nameservers={allNs}
overrideLocalDns={data.overrideDns}
/>
<ManageRecords isDisabled={isDisabled} records={data.extraRecords} />
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
<ManageNS nameservers={allNs} isDisabled={isDisabled} />
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
<ManageDomains
searchDomains={data.searchDomains}
isDisabled={isDisabled}
magic={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
/>
<div className="flex flex-col w-full sm:w-2/3">
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="mb-4">
Automatically register domain names for each device on the tailnet.
@@ -108,7 +103,7 @@ export default function Page() {
</Code>{' '}
when Magic DNS is enabled.
</p>
<ToggleMagic isDisabled={isDisabled} isEnabled={data.magicDns} />
<ToggleMagic isEnabled={data.magicDns} isDisabled={isDisabled} />
</div>
</div>
);
+190 -205
View File
@@ -1,216 +1,201 @@
import { ChevronDown, Copy } from "lucide-react";
import { useMemo } from "react";
import { Link } from "react-router";
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react';
import { useMemo } from 'react';
import { Link } from 'react-router';
import Chip from '~/components/Chip';
import Menu from '~/components/Menu';
import StatusCircle from '~/components/StatusCircle';
import type { HostInfo, Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import * as hinfo from '~/utils/host-info';
import type { User } from "~/types";
import Chip from "~/components/Chip";
import Menu from "~/components/Menu";
import StatusCircle from "~/components/StatusCircle";
import { ExitNodeTag } from "~/components/tags/ExitNode";
import { ExpiryTag } from "~/components/tags/Expiry";
import { HeadplaneAgentTag } from "~/components/tags/HeadplaneAgent";
import { SubnetTag } from "~/components/tags/Subnet";
import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
import cn from "~/utils/cn";
import * as hinfo from "~/utils/host-info";
import { PopulatedNode } from "~/utils/node-info";
import { formatTimeDelta } from "~/utils/time";
import toast from "~/utils/toast";
import MenuOptions from "./menu";
import toast from '~/utils/toast';
import MenuOptions from './menu';
interface Props {
node: PopulatedNode;
users: User[];
isAgent?: boolean;
magic?: string;
isDisabled?: boolean;
existingTags?: string[];
supportsNodeOwnerChange: boolean;
machine: Machine;
routes: Route[];
users: User[];
isAgent?: boolean;
magic?: string;
stats?: HostInfo;
isDisabled?: boolean;
}
export default function MachineRow({
node,
users,
isAgent,
magic,
isDisabled,
existingTags,
supportsNodeOwnerChange,
machine,
routes,
users,
isAgent,
magic,
stats,
isDisabled,
}: Props) {
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
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 ipOptions = useMemo(() => {
if (magic) {
return [...node.ipAddresses, `${node.givenName}.${magic}`];
}
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
return node.ipAddresses;
}, [magic, node.ipAddresses]);
if (expired) {
tags.unshift('Expired');
}
return (
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={node.id}>
<td className="py-2 pl-0.5 focus-within:ring-3">
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
<p
className={cn(
"font-semibold leading-snug",
"group-hover/link:text-blue-600",
"dark:group-hover/link:text-blue-400",
)}
>
{node.givenName}
</p>
<p className="text-sm opacity-50">
{node.user
? node.user.name || node.user.displayName || node.user.email || node.user.id
: "Tag-owned"}
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{mapTagsToComponents(node, uiTags)}
{node.tags?.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</Link>
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{node.ipAddresses[0]}
<Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses">
<ChevronDown className="h-4 w-4" />
</Menu.IconButton>
<Menu.Panel
onAction={async (key) => {
await navigator.clipboard.writeText(key.toString());
toast("Copied IP address to clipboard");
}}
>
<Menu.Section>
{ipOptions.map((ip) => (
<Menu.Item key={ip} textValue={ip}>
<div
className={cn("flex items-center justify-between", "text-sm w-full gap-x-6")}
>
{ip}
<Copy className="h-3 w-3" />
</div>
</Menu.Item>
))}
</Menu.Section>
</Menu.Panel>
</Menu>
</div>
</td>
{/* We pass undefined when agents are not enabled */}
{isAgent !== undefined ? (
<td className="py-2">
{node.hostInfo !== undefined ? (
<>
<p className="leading-snug">{hinfo.getTSVersion(node.hostInfo)}</p>
<p className="max-w-48 truncate text-sm opacity-50">
{hinfo.getOSInfo(node.hostInfo)}
</p>
</>
) : (
<p className="text-sm opacity-50">Unknown</p>
)}
</td>
) : undefined}
<td className="py-2">
<div className="flex items-start gap-x-1">
<StatusCircle className="mt-0.5 h-4 w-4" isOnline={node.online && !node.expired} />
<div>
<p
className={cn("text-sm", "text-headplane-600 dark:text-headplane-300")}
suppressHydrationWarning
>
{node.online && !node.expired
? "Connected"
: new Date(node.lastSeen).toLocaleString()}
</p>
{!(node.online && !node.expired) && (
<p className="text-xs opacity-50" suppressHydrationWarning>
{formatTimeDelta(new Date(node.lastSeen))}
</p>
)}
</div>
</div>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
existingTags={existingTags}
isDisabled={isDisabled}
magic={magic}
node={node}
users={users}
supportsNodeOwnerChange={supportsNodeOwnerChange}
/>
</td>
</tr>
);
}
export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
const uiTags: string[] = [];
if (node.expired) {
uiTags.push("expired");
}
if (node.expiry === null) {
uiTags.push("no-expiry");
}
if (node.customRouting.exitRoutes.length > 0) {
if (node.customRouting.exitApproved) {
uiTags.push("exit-approved");
} else {
uiTags.push("exit-waiting");
}
}
if (node.customRouting.subnetWaitingRoutes.length > 0) {
uiTags.push("subnet-waiting");
} else if (node.customRouting.subnetApprovedRoutes.length > 0) {
uiTags.push("subnet-approved");
}
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
uiTags.push("tailscale-ssh");
}
if (isAgent === true) {
uiTags.push("headplane-agent");
}
return uiTags;
}
export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
return uiTags.map((tag) => {
switch (tag) {
case "exit-approved":
case "exit-waiting":
return <ExitNodeTag isEnabled={tag === "exit-approved"} key={tag} />;
case "subnet-approved":
case "subnet-waiting":
return <SubnetTag isEnabled={tag === "subnet-approved"} key={tag} />;
case "expired":
case "no-expiry":
return <ExpiryTag expiry={node.expiry ?? undefined} key={tag} variant={tag} />;
case "tailscale-ssh":
return <TailscaleSSHTag key={tag} />;
case "headplane-agent":
return <HeadplaneAgentTag key={tag} />;
default:
return null;
}
});
const 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<{
exit: Route[];
subnetApproved: Route[];
subnet: Route[];
}>(
(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');
}
if (isAgent) {
tags.unshift('Headplane Agent');
}
const ipOptions = useMemo(() => {
if (magic) {
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
}
return machine.ipAddresses;
}, [magic, machine.ipAddresses]);
return (
<tr
key={machine.id}
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
>
<td className="pl-0.5 py-2 focus-within:ring">
<Link
to={`/machines/${machine.id}`}
className={cn('group/link h-full focus:outline-none')}
>
<p
className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400',
)}
>
{machine.givenName}
</p>
<p className="text-sm font-mono opacity-50">{machine.name}</p>
<div className="flex gap-1 mt-1">
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</Link>
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
<Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses">
<ChevronDownIcon className="w-4 h-4" />
</Menu.IconButton>
<Menu.Panel
onAction={async (key) => {
await navigator.clipboard.writeText(key.toString());
toast('Copied IP address to clipboard');
}}
>
<Menu.Section>
{ipOptions.map((ip) => (
<Menu.Item key={ip} textValue={ip}>
<div
className={cn(
'flex items-center justify-between',
'text-sm w-full gap-x-6',
)}
>
{ip}
<CopyIcon className="w-3 h-3" />
</div>
</Menu.Item>
))}
</Menu.Section>
</Menu.Panel>
</Menu>
</div>
</td>
{/* We pass undefined when agents are not enabled */}
{isAgent !== undefined ? (
<td className="py-2">
{stats !== undefined ? (
<>
<p className="leading-snug">{hinfo.getTSVersion(stats)}</p>
<p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(stats)}
</p>
</>
) : (
<p className="text-sm opacity-50">Unknown</p>
)}
</td>
) : undefined}
<td className="py-2">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-headplane-600 dark:text-headplane-300',
)}
>
<StatusCircle
isOnline={machine.online && !expired}
className="w-4 h-4"
/>
<p suppressHydrationWarning>
{machine.online && !expired
? 'Connected'
: new Date(machine.lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
isDisabled={isDisabled}
/>
</td>
</tr>
);
}
+35 -76
View File
@@ -1,10 +1,8 @@
import { Cog, Ellipsis, SquareTerminal } from 'lucide-react';
import { Cog, Ellipsis } from 'lucide-react';
import { useState } from 'react';
import Button from '~/components/Button';
import Menu from '~/components/Menu';
import type { User } from '~/types';
import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import { PopulatedNode } from '~/utils/node-info';
import Delete from '../dialogs/delete';
import Expire from '../dialogs/expire';
import Move from '../dialogs/move';
@@ -13,36 +11,39 @@ import Routes from '../dialogs/routes';
import Tags from '../dialogs/tags';
interface MenuProps {
node: PopulatedNode;
machine: Machine;
routes: Route[];
users: User[];
magic?: string;
isFullButton?: boolean;
isDisabled?: boolean;
existingTags?: string[];
supportsNodeOwnerChange: boolean;
}
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
export default function MachineMenu({
node,
machine,
routes,
magic,
users,
isFullButton,
isDisabled,
existingTags,
supportsNodeOwnerChange,
}: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
const supportsTailscaleSSH =
node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0;
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 (
<div className="flex items-center justify-end px-4 gap-1.5">
<>
{modal === 'remove' && (
<Delete
machine={machine}
isOpen={modal === 'remove'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
@@ -50,19 +51,19 @@ export default function MachineMenu({
)}
{modal === 'move' && (
<Move
machine={machine}
users={users}
isOpen={modal === 'move'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
users={users}
/>
)}
{modal === 'rename' && (
<Rename
isOpen={modal === 'rename'}
machine={node}
machine={machine}
magic={magic}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
@@ -70,8 +71,9 @@ export default function MachineMenu({
)}
{modal === 'routes' && (
<Routes
machine={machine}
routes={routes}
isOpen={modal === 'routes'}
node={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
@@ -79,67 +81,23 @@ export default function MachineMenu({
)}
{modal === 'tags' && (
<Tags
existingTags={existingTags}
machine={machine}
isOpen={modal === 'tags'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{node.expired && modal === 'expire' ? undefined : (
{expired && modal === 'expire' ? undefined : (
<Expire
machine={machine}
isOpen={modal === 'expire'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{supportsTailscaleSSH ? (
isFullButton ? (
<Button
className="flex items-center gap-x-2"
onPress={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
'_blank',
'noopener,noreferrer,width=800,height=600',
);
}}
variant="heavy"
>
<SquareTerminal className="h-5" />
<p>SSH</p>
</Button>
) : (
<Button
className={cn(
'py-0.5 w-fit bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
'opacity-0 pointer-events-none group-hover:opacity-100',
'group-hover:pointer-events-auto',
)}
onPress={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
'_blank',
'noopener,noreferrer,width=800,height=600',
);
}}
>
SSH
</Button>
)
) : undefined}
<Menu isDisabled={isDisabled}>
{isFullButton ? (
<Menu.Button className="flex items-center gap-x-2">
@@ -148,36 +106,37 @@ export default function MachineMenu({
</Menu.Button>
) : (
<Menu.IconButton
label="Machine Options"
className={cn(
'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
)}
label="Machine Options"
>
<Ellipsis className="h-5" />
</Menu.IconButton>
)}
<Menu.Panel
disabledKeys={node.expired ? ['expire'] : []}
onAction={(key) => setModal(key as Modal)}
>
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
<Menu.Section>
<Menu.Item key="rename">Edit machine name</Menu.Item>
<Menu.Item key="routes">Edit route settings</Menu.Item>
<Menu.Item key="tags">Edit ACL tags</Menu.Item>
{supportsNodeOwnerChange && <Menu.Item key="move">Change owner</Menu.Item>}
<Menu.Item key="move">Change owner</Menu.Item>
</Menu.Section>
<Menu.Section>
<Menu.Item key="expire" textValue="Expire">
<p className="text-red-500 dark:text-red-400">Expire</p>
</Menu.Item>
{expired ? (
<></>
) : (
<Menu.Item key="expire" textValue="Expire">
<p className="text-red-500 dark:text-red-400">Expire</p>
</Menu.Item>
)}
<Menu.Item key="remove" textValue="Remove">
<p className="text-red-500 dark:text-red-400">Remove</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
</div>
</>
);
}
+3 -3
View File
@@ -14,16 +14,16 @@ export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel
onSubmit={() => navigate('/machines')}
variant="destructive"
onSubmit={() => navigate('/machines')}
>
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from your network. To re-add
it, you will need to reauthenticate to your tailnet from the device.
</Dialog.Text>
<input name="action_id" type="hidden" value="delete" />
<input name="node_id" type="hidden" value={machine.id} />
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} />
</Dialog.Panel>
</Dialog>
);
+2 -2
View File
@@ -16,8 +16,8 @@ export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device.
</Dialog.Text>
<input name="action_id" type="hidden" value="expire" />
<input name="node_id" type="hidden" value={machine.id} />
<input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} />
</Dialog.Panel>
</Dialog>
);
+29 -39
View File
@@ -1,45 +1,35 @@
import { Key, useState } from "react";
import type { Machine, User } from "~/types";
import Dialog from "~/components/Dialog";
import Select from "~/components/Select";
import Dialog from '~/components/Dialog';
import Select from '~/components/Select';
import type { Machine, User } from '~/types';
interface MoveProps {
machine: Machine;
users: User[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
machine: Machine;
users: User[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
const [userId, setUserId] = useState<Key | null>(machine.user?.id ?? null);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel isDisabled={userId === machine.user?.id}>
<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>
<input name="action_id" type="hidden" value="reassign" />
<input name="node_id" type="hidden" value={machine.id} />
<input name="user_id" type="hidden" value={userId?.toString()} />
<Select
defaultSelectedKey={machine.user?.id}
isRequired
label="Owner"
name="user"
onSelectionChange={(key) => {
setUserId(key);
}}
placeholder="Select a user"
>
{users.map((user) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<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>
<input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} />
<Select
label="Owner"
name="to"
placeholder="Select a user"
defaultSelectedKey={machine.user.id}
>
{users.map((user) => (
<Select.Item key={user.id}>{user.name}</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
);
}
+9 -15
View File
@@ -1,4 +1,4 @@
import { Computer, FileKey2 } from 'lucide-react';
import { Computer, KeySquare } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import Code from '~/components/Code';
@@ -12,7 +12,6 @@ export interface NewMachineProps {
server: string;
users: User[];
isDisabled?: boolean;
disabledKeys?: string[];
}
export default function NewMachine(data: NewMachineProps) {
@@ -20,28 +19,25 @@ export default function NewMachine(data: NewMachineProps) {
const [mkey, setMkey] = useState('');
const navigate = useNavigate();
const isMkeyInvalid = mkey.length > 0 && mkey.length !== 24;
return (
<>
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
<Dialog.Panel isDisabled={mkey.length !== 24}>
<Dialog.Panel isDisabled={mkey.length < 1}>
<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>
<input name="action_id" type="hidden" value="register" />
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<Input
errorMessage="Machine key must be exactly 24 characters"
isInvalid={isMkeyInvalid}
isRequired
label="Machine Key"
name="register_key"
onChange={setMkey}
placeholder="AbCd..."
validationBehavior="native"
name="mkey"
onChange={setMkey}
/>
<Select
isRequired
@@ -50,14 +46,12 @@ export default function NewMachine(data: NewMachineProps) {
placeholder="Select a user"
>
{data.users.map((user) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
<Select.Item key={user.id}>{user.name}</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
<Menu disabledKeys={data.disabledKeys} isDisabled={data.isDisabled}>
<Menu isDisabled={data.isDisabled}>
<Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel
onAction={(key) => {
@@ -80,7 +74,7 @@ export default function NewMachine(data: NewMachineProps) {
</Menu.Item>
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
<div className="flex items-center gap-x-3">
<FileKey2 className="w-4" />
<KeySquare className="w-4" />
Generate Pre-auth Key
</div>
</Menu.Item>
+5 -36
View File
@@ -27,45 +27,14 @@ export default function Rename({
This name is shown in the admin panel, in Tailscale clients, and used
when generating MagicDNS names.
</Dialog.Text>
<input name="action_id" type="hidden" value="rename" />
<input name="node_id" type="hidden" value={machine.id} />
<input type="hidden" name="_method" value="rename" />
<input type="hidden" name="id" value={machine.id} />
<Input
defaultValue={machine.givenName}
isRequired
label="Machine name"
name="name"
onChange={setName}
placeholder="Machine name"
validate={(value) => {
if (value.length === 0) {
return 'Cannot be empty';
}
// DNS hostname validation
if (value.toLowerCase() !== value) {
return 'Cannot contain uppercase letters';
}
if (value.length > 63) {
return 'DNS hostnames cannot be 64+ characters';
}
// Test for invalid characters
if (!/^[a-z0-9-]+$/.test(value)) {
return 'Cannot contain special characters';
}
// Test for leading/trailing hyphens
if (value.startsWith('-') || value.endsWith('-')) {
return 'Cannot start or end with a hyphen';
}
// Test for consecutive hyphens
if (value.includes('--')) {
return 'Cannot contain consecutive hyphens';
}
}}
validationBehavior="native"
name="name"
defaultValue={machine.givenName}
onChange={setName}
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
+48 -28
View File
@@ -1,43 +1,68 @@
import { GlobeLock, RouteOff } from 'lucide-react';
import { useMemo } from 'react';
import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import Switch from '~/components/Switch';
import TableList from '~/components/TableList';
import { PopulatedNode } from '~/utils/node-info';
import type { Machine, Route } from '~/types';
import cn from '~/utils/cn';
interface RoutesProps {
node: PopulatedNode;
machine: Machine;
routes: Route[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
// TODO: Support deleting routes
export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
export default function Routes({
machine,
routes,
isOpen,
setIsOpen,
}: RoutesProps) {
const fetcher = useFetcher();
const subnets = [
...node.customRouting.subnetApprovedRoutes,
...node.customRouting.subnetWaitingRoutes,
];
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet } = routes.reduce<{
exit: Route[];
subnet: Route[];
}>(
(acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route);
return acc;
}
acc.subnet.push(route);
return acc;
},
{ exit: [], subnet: [] },
);
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled;
}, [exit]);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="unactionable">
<Dialog.Title>Edit route settings of {node.givenName}</Dialog.Title>
<Dialog.Title>Edit route settings of {machine.givenName}</Dialog.Title>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on by advertising
IP ranges as subnet routes.{' '}
<Link
name="Tailscale Subnets Documentation"
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
>
Learn More
</Link>
</Dialog.Text>
<TableList className="mt-4">
{subnets.length === 0 ? (
{subnet.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<RouteOff />
<p className="font-semibold">
@@ -45,17 +70,17 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
</p>
</TableList.Item>
) : undefined}
{subnets.map((route) => (
<TableList.Item key={route}>
<p>{route}</p>
{subnet.map((route) => (
<TableList.Item key={route.id}>
<p>{route.prefix}</p>
<Switch
defaultSelected={node.approvedRoutes.includes(route)}
defaultSelected={route.enabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set('action_id', 'update_routes');
form.set('node_id', node.id);
form.set('routes', [route].join(','));
form.set('id', machine.id);
form.set('_method', 'routes');
form.set('route', route.id);
form.set('enabled', String(checked));
fetcher.submit(form, {
@@ -70,14 +95,14 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
<Dialog.Text>
Allow your network to route internet traffic through this machine.{' '}
<Link
name="Tailscale Exit-node Documentation"
to="https://tailscale.com/kb/1103/exit-nodes"
name="Tailscale Exit-node Documentation"
>
Learn More
</Link>
</Dialog.Text>
<TableList className="mt-4">
{node.customRouting.exitRoutes.length === 0 ? (
{exit.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<GlobeLock />
<p className="font-semibold">This machine is not an exit node</p>
@@ -86,18 +111,13 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
<TableList.Item>
<p>Use as exit node</p>
<Switch
defaultSelected={node.customRouting.exitApproved}
defaultSelected={exitEnabled}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set('action_id', 'update_routes');
form.set('node_id', node.id);
form.set(
'routes',
node.customRouting.exitRoutes
.map((route) => route)
.join(','),
);
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, {
+95 -135
View File
@@ -1,141 +1,101 @@
import { Plus, TagsIcon, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useFetcher } from "react-router";
import type { Machine } from "~/types";
import Button from "~/components/Button";
import Dialog from "~/components/Dialog";
import Link from "~/components/Link";
import Select from "~/components/Select";
import TableList from "~/components/TableList";
import cn from "~/utils/cn";
import { Plus, TagsIcon, X } from 'lucide-react';
import { useState } from 'react';
import Button from '~/components/Button';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Link from '~/components/Link';
import TableList from '~/components/TableList';
import type { Machine } from '~/types';
import cn from '~/utils/cn';
interface TagsProps {
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
existingTags?: string[];
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsProps) {
const fetcher = useFetcher();
const submittingRef = useRef(false);
const [tags, setTags] = useState([...machine.tags]);
const [tag, setTag] = useState("tag:");
const tagIsInvalid = useMemo(() => {
return tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag);
}, [tag, tags]);
export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState('');
const validNodeTags = useMemo(() => {
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
}, [tags]);
const error = fetcher.data && !fetcher.data.success ? fetcher.data.error : null;
useEffect(() => {
if (fetcher.data?.success) {
submittingRef.current = false;
setIsOpen(false);
}
if (fetcher.state === "idle" && fetcher.data && !fetcher.data.success) {
submittingRef.current = false;
}
}, [fetcher.data, fetcher.state]);
useEffect(() => {
if (isOpen) {
setTags([...machine.tags]);
}
}, [isOpen]);
return (
<Dialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open && submittingRef.current) return;
setIsOpen(open);
}}
>
<Dialog.Panel
onSubmit={(event) => {
event.preventDefault();
submittingRef.current = true;
const form = new FormData();
form.set("action_id", "update_tags");
form.set("node_id", machine.id);
form.set("tags", tags.filter((t) => t !== "").join(","));
fetcher.submit(form, { method: "POST" });
}}
isDisabled={fetcher.state !== "idle"}
>
<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 name="Tailscale documentation" to="https://tailscale.com/kb/1068/acl-tags">
Tailscale documentation
</Link>{" "}
for more information.
</Dialog.Text>
{error ? (
<p className="mt-2 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{error}
</p>
) : null}
<TableList className="mt-4">
{tags.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<TagsIcon />
<p className="font-semibold">No tags are set on this machine</p>
</TableList.Item>
) : (
tags.map((item) => (
<TableList.Item className="font-mono" id={item} key={item}>
{item}
<Button
className="rounded-md p-0.5"
onPress={() => {
setTags(tags.filter((tag) => tag !== item));
}}
>
<X className="p-1" />
</Button>
</TableList.Item>
))
)}
</TableList>
<div className="mt-2 flex items-center gap-2">
<Select
allowsCustomValue
aria-label="Add a tag"
className="w-full"
inputValue={tag}
isInvalid={tag.length > 0 && tagIsInvalid}
onInputChange={setTag}
placeholder="tag:example"
>
{validNodeTags.map((nodeTag) => {
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
})}
</Select>
<Button
className={cn("rounded-md p-1", tagIsInvalid && "opacity-50 cursor-not-allowed")}
isDisabled={tagIsInvalid}
onPress={() => {
setTags([...tags, tag]);
setTag("tag:");
}}
>
<Plus className="p-1" size={30} />
</Button>
</div>
<p className="mt-2 text-sm opacity-50">
Not seeing the tags you expect? Tags need to be defined in your access control policy
before they can be assigned to machines.
</p>
</Dialog.Panel>
</Dialog>
);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<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>
<input type="hidden" name="_method" value="tags" />
<input type="hidden" name="id" value={machine.id} />
<input type="hidden" name="tags" value={tags.join(',')} />
<TableList className="mt-4">
{tags.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<TagsIcon />
<p className="font-semibold">No tags are set on this machine</p>
</TableList.Item>
) : (
tags.map((item) => (
<TableList.Item className="font-mono" key={item} id={item}>
{item}
<Button
className="rounded-md p-0.5"
onPress={() => {
setTags(tags.filter((tag) => tag !== item));
}}
>
<X className="p-1" />
</Button>
</TableList.Item>
))
)}
<TableList.Item
className={cn(
'rounded-b-xl focus-within:ring',
tag.length > 0 &&
(!tag.startsWith('tag:') || tags.includes(tag)) &&
'ring ring-red-500 ring-opacity-50',
)}
>
<Input
labelHidden
label="Add a tag"
placeholder="tag:example"
onChange={setTag}
className={cn(
'border-none font-mono p-0',
'rounded-none focus:ring-0 w-full',
)}
/>
<Button
className={cn(
'rounded-md p-0.5',
(!tag.startsWith('tag:') || tags.includes(tag)) &&
'opacity-50 cursor-not-allowed',
)}
isDisabled={
tag.length === 0 ||
!tag.startsWith('tag:') ||
tags.includes(tag)
}
onPress={() => {
setTags([...tags, tag]);
setTag('');
}}
>
<Plus className="p-1" />
</Button>
</TableList.Item>
</TableList>
</Dialog.Panel>
</Dialog>
);
}
+216 -162
View File
@@ -1,193 +1,247 @@
import { data, redirect } from "react-router";
import type { ActionFunctionArgs } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { Machine } from '~/types';
import log from '~/utils/log';
import { data400, data403, data404, send } from '~/utils/res';
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { Capabilities } from "~/server/web/roles";
// TODO: Clean this up like dns-actions and user-actions
export async function machineAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_machines,
);
import type { Route } from "./+types/machine";
const apiKey = session.get('api_key')!;
const formData = await request.formData();
export async function machineAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.write_machines);
// TODO: Rename this to 'action_id' and 'node_id'
const action = formData.get('_method')?.toString();
const nodeId = formData.get('id')?.toString();
if (!action || !nodeId) {
return data400('Missing required parameters: _method and id');
}
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(session.api_key);
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
apiKey,
);
const action = formData.get("action_id")?.toString();
if (!action) {
throw data("Missing `action_id` in the form data.", {
status: 400,
});
}
const node = nodes.find((node) => node.id === nodeId);
if (!node) {
return data404(`Node with ID ${nodeId} not found`);
}
// Fast track register since it doesn't require an existing machine
if (action === "register") {
if (!check) {
throw data("You do not have permission to manage machines", {
status: 403,
});
}
const subject = session.get('user')!.subject;
if (node.user.providerId?.split('/').pop() !== subject) {
if (!check) {
return data403('You do not have permission to act on this machine');
}
}
const registrationKey = formData.get("register_key")?.toString();
if (!registrationKey) {
throw data("Missing `register_key` in the form data.", {
status: 400,
});
}
// TODO: Split up into methods
switch (action) {
case 'delete': {
await context.client.delete(`v1/node/${nodeId}`, session.get('api_key')!);
return { message: 'Machine removed' };
}
const user = formData.get("user")?.toString();
if (!user) {
throw data("Missing `user` in the form data.", {
status: 400,
});
}
case 'expire': {
await context.client.post(
`v1/node/${nodeId}/expire`,
session.get('api_key')!,
);
return { message: 'Machine expired' };
}
const node = await api.registerNode(user, registrationKey);
return redirect(`/machines/${node.id}`);
}
case 'rename': {
if (!formData.has('name')) {
return send(
{ message: 'No name provided' },
{
status: 400,
},
);
}
// Check if the user has permission to manage this machine
const nodeId = formData.get("node_id")?.toString();
if (!nodeId) {
throw data("Missing `node_id` in the form data.", {
status: 400,
});
}
const name = String(formData.get('name'));
await context.client.post(
`v1/node/${nodeId}/rename/${name}`,
session.get('api_key')!,
);
return { message: 'Machine renamed' };
}
const node = await api.getNode(nodeId);
if (!node) {
throw data(`Machine with ID ${nodeId} not found`, {
status: 404,
});
}
case 'routes': {
if (!formData.has('route') || !formData.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
// Tag-only nodes (Headscale 0.28+) have no user, so we rely on role-based permissions
const nodeOwnerId = node.user?.providerId?.split("/").pop();
if (nodeOwnerId !== session.user.subject && !check) {
throw data("You do not have permission to act on this machine", {
status: 403,
});
}
const route = String(formData.get('route'));
const enabled = formData.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
switch (action) {
case "rename": {
const newName = formData.get("name")?.toString();
if (!newName) {
throw data("Missing `name` in the form data.", {
status: 400,
});
}
await context.client.post(
`v1/routes/${route}/${postfix}`,
session.get('api_key')!,
);
return { message: 'Route updated' };
}
const name = String(formData.get("name"));
await api.renameNode(nodeId, name);
return { message: "Machine renamed" };
}
case 'exit-node': {
if (!formData.has('routes') || !formData.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
case "delete": {
await api.deleteNode(nodeId);
return redirect("/machines");
}
const routes = formData.get('routes')?.toString().split(',') ?? [];
const enabled = formData.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
case "expire": {
await api.expireNode(nodeId);
return { message: "Machine expired" };
}
await Promise.all(
routes.map(async (route) => {
await context.client.post(
`v1/routes/${route}/${postfix}`,
session.get('api_key')!,
);
}),
);
case "update_tags": {
const tags = formData.get("tags")?.toString().split(",") ?? [];
if (tags.length === 0) {
throw data("Missing `tags` in the form data.", {
status: 400,
});
}
return { message: 'Exit node updated' };
}
try {
await api.setNodeTags(
nodeId,
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
);
case 'move': {
if (!formData.has('to')) {
return send(
{ message: 'No destination provided' },
{
status: 400,
},
);
}
return { success: true as const, message: "Tags updated" };
} catch (error) {
if (isDataWithApiError(error) && error.data.statusCode === 400) {
return data(
{
success: false as const,
error:
"One or more tags are not defined in your ACL policy. Please add them to your policy before assigning them to a machine.",
},
{ status: 400 },
);
}
const to = String(formData.get('to'));
throw error;
}
}
try {
await context.client.post(
`v1/node/${nodeId}/user`,
session.get('api_key')!,
{
user: to,
},
);
case "update_routes": {
const newApproved = node.approvedRoutes;
const routes = formData.get("routes")?.toString();
if (!routes) {
throw data("Missing `routes` in the form data.", {
status: 400,
});
}
return { message: `Moved node ${nodeId} to ${to}` };
} catch (error) {
console.error(error);
return send(
{ message: `Failed to move node ${nodeId} to ${to}` },
{
status: 500,
},
);
}
}
const allRoutes = routes.split(",").map((route) => route.trim());
if (allRoutes.length === 0) {
throw data("No routes provided to update", {
status: 400,
});
}
case 'tags': {
const tags =
formData
.get('tags')
?.toString()
.split(',')
.filter((tag) => tag.trim() !== '') ?? [];
const enabled = formData.get("enabled")?.toString();
if (enabled === undefined) {
throw data("Missing `enabled` in the form data.", {
status: 400,
});
}
try {
await context.client.post(
`v1/node/${nodeId}/tags`,
session.get('api_key')!,
{
tags,
},
);
if (enabled === "true") {
for (const route of allRoutes) {
// If already approved, skip, otherwise add to approved
if (newApproved.includes(route)) {
continue;
}
return { message: 'Tags updated' };
} catch (error) {
log.debug('api', 'Failed to update tags: %s', error);
return send(
{ message: 'Failed to update tags' },
{
status: 500,
},
);
}
}
newApproved.push(route);
}
} else {
for (const route of allRoutes) {
// If not approved, skip, otherwise remove from approved
if (!newApproved.includes(route)) {
continue;
}
case 'register': {
const key = formData.get('mkey')?.toString();
const user = formData.get('user')?.toString();
const index = newApproved.indexOf(route);
if (index > -1) {
newApproved.splice(index, 1);
}
}
}
if (!key) {
return send(
{ message: 'No machine key provided' },
{
status: 400,
},
);
}
await api.approveNodeRoutes(nodeId, newApproved);
return { message: "Routes updated" };
}
if (!user) {
return send(
{ message: 'No user provided' },
{
status: 400,
},
);
}
case "reassign": {
const user = formData.get("user_id")?.toString();
if (!user) {
throw data("Missing `user_id` in the form data.", {
status: 400,
});
}
try {
const qp = new URLSearchParams();
qp.append('user', user);
qp.append('key', key);
await api.setNodeUser(nodeId, user);
return { message: "Machine reassigned" };
}
const url = `v1/node/register?${qp.toString()}`;
await context.client.post(url, session.get('api_key')!, {
user,
key,
});
default:
throw data("Invalid action", {
status: 400,
});
}
return {
success: true,
message: 'Machine registered',
};
} catch {
return send(
{
success: false,
message: 'Failed to register machine',
},
{
status: 500,
},
);
}
}
default: {
return send(
{ message: 'Invalid method' },
{
status: 400,
},
);
}
}
}
+327 -352
View File
@@ -1,362 +1,337 @@
import { CheckCircle, CircleSlash, Info, UserCircle } from "lucide-react";
import { useMemo, useState } from "react";
import { data, Link as RemixLink } from "react-router";
import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { Link as RemixLink, useLoaderData } from 'react-router';
import Attribute from '~/components/Attribute';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Chip from '~/components/Chip';
import Link from '~/components/Link';
import StatusCircle from '~/components/StatusCircle';
import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import MenuOptions from './components/menu';
import Routes from './dialogs/routes';
import { machineAction } from './machine-actions';
import Attribute from "~/components/Attribute";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Chip from "~/components/Chip";
import Link from "~/components/Link";
import StatusCircle from "~/components/StatusCircle";
import Tooltip from "~/components/Tooltip";
import cn from "~/utils/cn";
import { getOSInfo, getTSVersion } from "~/utils/host-info";
import { mapNodes, sortNodeTags } from "~/utils/node-info";
export async function loader({
request,
params,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
if (!params.id) {
throw new Error('No machine ID provided');
}
import type { Route } from "./+types/machine";
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
import { mapTagsToComponents, uiTagsForNode } from "./components/machine-row";
import MenuOptions from "./components/menu";
import Routes from "./dialogs/routes";
import { machineAction } from "./machine-actions";
const [machine, routes, users] = await Promise.all([
context.client.get<{ node: Machine }>(
`v1/node/${params.id}`,
session.get('api_key')!,
),
context.client.get<{ routes: Route[] }>(
'v1/routes',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]);
export async function loader({ request, params, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
if (!params.id) {
throw new Error("No machine ID provided");
}
if (params.id.endsWith(".ico")) {
throw data(null, { status: 204 });
}
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
const node = nodes.find((node) => node.id === params.id);
const lookup = await context.agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup);
const tags = [...node.tags].sort();
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
return {
node: enhancedNode,
tags,
users,
magic,
agent: context.agents?.agentID(),
stats: lookup?.[enhancedNode.nodeKey],
existingTags: sortNodeTags(nodes),
supportsNodeOwnerChange: supportsNodeOwnerChange,
};
return {
machine: machine.node,
routes: routes.routes.filter((route) => route.node.id === params.id),
users: users.users,
magic,
// TODO: Fix agent
agent: false,
// agent: [...(hp_getSingletonUnsafe('ws_agents') ?? []).keys()].includes(
// machine.node.id,
// ),
};
}
export const action = machineAction;
export default function Page({
loaderData: { node, tags, users, magic, agent, stats, existingTags, supportsNodeOwnerChange },
}: Route.ComponentProps) {
const [showRouting, setShowRouting] = useState(false);
const uiTags = useMemo(() => {
const tags = uiTagsForNode(node, agent === node.nodeKey);
return tags;
}, [node, agent]);
return (
<div>
<p className="text-md mb-8">
<RemixLink className="font-medium" to="/machines">
All Machines
</RemixLink>
<span className="mx-2">/</span>
{node.givenName}
</p>
<div
className={cn(
"flex justify-between items-center pb-2",
"border-b border-headplane-100 dark:border-headplane-800",
)}
>
<span className="flex items-baseline gap-x-4 text-sm">
<h1 className="text-2xl font-medium">{node.givenName}</h1>
<StatusCircle className="h-4 w-4" isOnline={node.online} />
</span>
<MenuOptions
existingTags={existingTags}
isFullButton
magic={magic}
node={node}
users={users}
supportsNodeOwnerChange={supportsNodeOwnerChange}
/>
</div>
<div className="mb-4 flex gap-1">
<div className="border-headplane-100 dark:border-headplane-800 border-r p-2 pr-4">
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1 text-sm">
Managed by
<Tooltip>
<Info className="p-1" />
<Tooltip.Body>By default, a machines permissions match its creators.</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1 flex items-center gap-x-2.5">
<UserCircle />
{node.user
? node.user.name || node.user.displayName || node.user.email || node.user.id
: "Tag-owned"}
</div>
</div>
<div className="p-2 pl-4">
<p className="text-headplane-600 dark:text-headplane-300 text-sm">Status</p>
<div className="mt-1 mb-8 flex gap-1">
{mapTagsToComponents(node, uiTags)}
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</div>
</div>
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
<h2 className="mt-8 text-xl font-medium">Subnets & Routing</h2>
<div className="mb-4 flex items-center justify-between">
<p>
Subnets let you expose physical network routes onto Tailscale.{" "}
<Link name="Tailscale Subnets Documentation" to="https://tailscale.com/kb/1019/subnets">
Learn More
</Link>
</p>
<Button onPress={() => setShowRouting(true)}>Review</Button>
</div>
<Card
className={cn(
"w-full max-w-full grid sm:grid-cols-2",
"md:grid-cols-4 gap-8 mr-2 text-sm mb-8",
)}
variant="flat"
>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Approved
<Tooltip>
<Info className="h-3.5 w-3.5" />
<Tooltip.Body>
Traffic to these routes are being routed through this machine.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
<span className="opacity-50"></span>
) : (
<ul className="leading-normal">
{node.customRouting.subnetApprovedRoutes.map((route) => (
<li key={route}>{route}</li>
))}
</ul>
)}
</div>
<Button
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
</div>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Awaiting Approval
<Tooltip>
<Info className="h-3.5 w-3.5" />
<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">
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
<span className="opacity-50"></span>
) : (
<ul className="leading-normal">
{node.customRouting.subnetWaitingRoutes.map((route) => (
<li key={route}>{route}</li>
))}
</ul>
)}
</div>
<Button
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
</div>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Exit Node
<Tooltip>
<Info className="h-3.5 w-3.5" />
<Tooltip.Body>
Whether this machine can act as an exit node for your tailnet.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{node.customRouting.exitRoutes.length === 0 ? (
<span className="opacity-50"></span>
) : node.customRouting.exitApproved ? (
<span className="flex items-center gap-x-1">
<CheckCircle className="h-3.5 w-3.5 text-green-700" />
Allowed
</span>
) : (
<span className="flex items-center gap-x-1">
<CircleSlash className="h-3.5 w-3.5 text-red-700" />
Awaiting Approval
</span>
)}
</div>
<Button
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
</div>
</Card>
<h2 className="text-xl font-medium">Machine Details</h2>
<p className="mb-4">
Information about this machines network. Used to debug connection issues.
</p>
<Card
className="grid w-full max-w-full grid-cols-1 gap-y-2 sm:gap-x-12 lg:grid-cols-2"
variant="flat"
>
<div className="flex flex-col gap-1">
<Attribute
name="Creator"
value={
node.user
? node.user.name || node.user.displayName || node.user.email || node.user.id
: "Tag-owned"
}
/>
<Attribute name="Machine name" value={node.givenName} />
<Attribute
name="OS hostname"
tooltip="OS hostname is published by the machines operating system and is used as the default name for the machine."
value={node.name}
/>
{stats ? (
<>
<Attribute name="OS" value={getOSInfo(stats)} />
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
</>
) : undefined}
<Attribute
name="ID"
tooltip="ID for this machine. Used in the Headscale API."
value={node.id}
/>
<Attribute
isCopyable
name="Node key"
tooltip="Public key which uniquely identifies this machine."
value={node.nodeKey}
/>
<Attribute name="Created" value={new Date(node.createdAt).toLocaleString()} />
<Attribute
name="Last Seen"
value={node.online ? "Connected" : new Date(node.lastSeen).toLocaleString()}
/>
<Attribute
name="Key expiry"
value={node.expiry !== null ? new Date(node.expiry).toLocaleString() : "Never"}
/>
{magic ? (
<Attribute isCopyable name="Domain" value={`${node.givenName}.${magic}`} />
) : undefined}
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold uppercase opacity-75">Addresses</p>
<Attribute
isCopyable
name="Tailscale IPv4"
tooltip="This machines IPv4 address within your tailnet (your private Tailscale network)."
value={getIpv4Address(node.ipAddresses)}
/>
<Attribute
isCopyable
name="Tailscale IPv6"
tooltip="This machines IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
value={getIpv6Address(node.ipAddresses)}
/>
<Attribute
isCopyable
name="Short domain"
tooltip="Users of your tailnet can use this DNS short name to access this machine."
value={node.givenName}
/>
{magic ? (
<Attribute
isCopyable
name="Full domain"
tooltip="Users of your tailnet can use this DNS name to access this machine."
value={`${node.givenName}.${magic}`}
/>
) : undefined}
{stats ? (
<>
<p className="mt-4 text-sm font-semibold uppercase opacity-75">Client Connectivity</p>
<Attribute
name="Varies"
tooltip="Whether the machine is behind a difficult NAT that varies the machines IP address depending on the destination."
value={stats.NetInfo?.MappingVariesByDestIP ? "Yes" : "No"}
/>
<Attribute
name="Hairpinning"
tooltip="Whether the machine needs to traverse NATs with hairpinning."
value={stats.NetInfo?.HairPinning ? "Yes" : "No"}
/>
<Attribute name="IPv6" value={stats.NetInfo?.WorkingIPv6 ? "Yes" : "No"} />
<Attribute name="UDP" value={stats.NetInfo?.WorkingUDP ? "Yes" : "No"} />
<Attribute name="UPnP" value={stats.NetInfo?.UPnP ? "Yes" : "No"} />
<Attribute name="PCP" value={stats.NetInfo?.PCP ? "Yes" : "No"} />
<Attribute name="NAT-PMP" value={stats.NetInfo?.PMP ? "Yes" : "No"} />
</>
) : undefined}
</div>
</Card>
</div>
);
export async function action(request: ActionFunctionArgs) {
return machineAction(request);
}
function getIpv4Address(addresses: string[]) {
for (const address of addresses) {
if (address.startsWith("100.")) {
// Return the first CGNAT address
return address;
}
}
export default function Page() {
const { machine, magic, routes, users, agent } =
useLoaderData<typeof loader>();
const [showRouting, setShowRouting] = useState(false);
return "—";
}
function getIpv6Address(addresses: string[]) {
for (const address of addresses) {
if (address.startsWith("fd")) {
// Return the first IPv6 address
return address;
}
}
return "—";
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 = [...new Set([...machine.forcedTags, ...machine.validTags])];
if (expired) {
tags.unshift('Expired');
}
if (agent) {
tags.unshift('Headplane Agent');
}
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce<{
exit: Route[];
subnet: Route[];
subnetApproved: Route[];
}>(
(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 pb-2',
'border-b border-headplane-100 dark:border-headplane-800',
)}
>
<span className="flex items-baseline gap-x-4 text-sm">
<h1 className="text-2xl font-medium">{machine.givenName}</h1>
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
</span>
<MenuOptions
isFullButton
machine={machine}
routes={routes}
users={users}
magic={magic}
/>
</div>
<div className="flex gap-1 mb-4">
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
<span className="text-sm text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Managed by
<Tooltip>
<Info className="p-1" />
<Tooltip.Body>
By default, a machines permissions match its creators.
</Tooltip.Body>
</Tooltip>
</span>
<div className="flex items-center gap-x-2.5 mt-1">
<UserCircle />
{machine.user.name}
</div>
</div>
{tags.length > 0 ? (
<div className="p-2 pl-4">
<p className="text-sm text-headplane-600 dark:text-headplane-300">
Status
</p>
<div className="flex gap-1 mt-1 mb-8">
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</div>
) : undefined}
</div>
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
<Routes
machine={machine}
routes={routes}
isOpen={showRouting}
setIsOpen={setShowRouting}
/>
<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 onPress={() => setShowRouting(true)}>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-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Approved
<Tooltip>
<Info className="w-3.5 h-3.5" />
<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="opacity-50"></span>
) : (
<ul className="leading-normal">
{subnetApproved.map((route) => (
<li key={route.id}>{route.prefix}</li>
))}
</ul>
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
>
Edit
</Button>
</div>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Awaiting Approval
<Tooltip>
<Info className="w-3.5 h-3.5" />
<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="opacity-50"></span>
) : (
<ul className="leading-normal">
{subnet.map((route) => (
<li key={route.id}>{route.prefix}</li>
))}
</ul>
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
>
Edit
</Button>
</div>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Exit Node
<Tooltip>
<Info className="w-3.5 h-3.5" />
<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="opacity-50"></span>
) : exitEnabled ? (
<span className="flex items-center gap-x-1">
<CheckCircle className="w-3.5 h-3.5 text-green-700" />
Allowed
</span>
) : (
<span className="flex items-center gap-x-1">
<CircleSlash className="w-3.5 h-3.5 text-red-700" />
Awaiting Approval
</span>
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
>
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
suppressHydrationWarning
name="Created"
value={new Date(machine.createdAt).toLocaleString()}
/>
<Attribute
suppressHydrationWarning
name="Last Seen"
value={new Date(machine.lastSeen).toLocaleString()}
/>
<Attribute
suppressHydrationWarning
name="Expiry"
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
/>
{magic ? (
<Attribute
isCopyable
name="Domain"
value={`${machine.givenName}.${magic}`}
/>
) : undefined}
</Card>
</div>
);
}
+157 -371
View File
@@ -1,381 +1,167 @@
import { ChevronDown, ChevronUp, Info, X } from "lucide-react";
import { useMemo, useState } from "react";
import { InfoIcon } from '@primer/octicons-react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import Code from '~/components/Code';
import { ErrorPopup } from '~/components/Error';
import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import MachineRow from './components/machine-row';
import NewMachine from './dialogs/new';
import { machineAction } from './machine-actions';
import Code from "~/components/Code";
import Input from "~/components/Input";
import Link from "~/components/Link";
import Tooltip from "~/components/Tooltip";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { mapNodes, sortNodeTags } from "~/utils/node-info";
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const user = session.get('user');
if (!user) {
throw new Error('Missing user session. Please log in again.');
}
import type { Route } from "./+types/overview";
const check = await context.sessions.check(
request,
Capabilities.read_machines,
);
import MachineRow from "./components/machine-row";
import NewMachine from "./dialogs/new";
import { machineAction } from "./machine-actions";
if (!check) {
// Not authorized to view this page
throw new Error(
'You do not have permission to view this page. Please contact your administrator.',
);
}
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const user = session.user;
if (!user) {
throw new Error("Missing user session. Please log in again.");
}
const writablePermission = await context.sessions.check(
request,
Capabilities.write_machines,
);
const check = await context.sessions.check(request, Capabilities.read_machines);
const [machines, routes, users] = await Promise.all([
context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
),
context.client.get<{ routes: Route[] }>(
'v1/routes',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]);
if (!check) {
// Not authorized to view this page
throw new Error(
"You do not have permission to view this page. Please contact your administrator.",
);
}
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
const writablePermission = await context.sessions.check(request, Capabilities.write_machines);
const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
return {
populatedNodes,
nodes,
users,
magic,
server: context.config.headscale.url,
publicServer: context.config.headscale.public_url,
agent: context.agents?.agentID(),
writable: writablePermission,
preAuth: await context.sessions.check(request, Capabilities.generate_authkeys),
subject: user.subject,
supportsNodeOwnerChange: supportsNodeOwnerChange,
};
return {
nodes: machines.nodes,
routes: routes.routes,
users: users.users,
magic,
server: context.config.headscale.url,
publicServer: context.config.headscale.public_url,
agents: context.agents?.tailnetIDs(),
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
writable: writablePermission,
subject: user.subject,
};
}
export const action = machineAction;
type SortField = "name" | "ip" | "version" | "lastSeen";
export default function Page({ loaderData }: Route.ComponentProps) {
const [searchQuery, setSearchQuery] = useState("");
const [sortField, setSortField] = useState<SortField>("name");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const filteredAndSortedNodes = useMemo(() => {
const query = searchQuery.toLowerCase().trim();
let nodes = loaderData.populatedNodes.filter((node) => {
if (!query) return true;
if (node.givenName.toLowerCase().includes(query)) return true;
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) return true;
return false;
});
nodes = [...nodes].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case "name":
comparison = a.givenName.localeCompare(b.givenName);
break;
case "ip": {
const getIPv4 = (addresses: string[]) =>
addresses.find((ip) => !ip.includes(":")) || addresses[0] || "";
const ipA = getIPv4(a.ipAddresses);
const ipB = getIPv4(b.ipAddresses);
if (!ipA.includes(":") && !ipB.includes(":")) {
const octetsA = ipA.split(".").map(Number);
const octetsB = ipB.split(".").map(Number);
for (let i = 0; i < 4; i++) {
if (octetsA[i] !== octetsB[i]) {
comparison = octetsA[i] - octetsB[i];
break;
}
}
} else {
comparison = ipA.localeCompare(ipB);
}
break;
}
case "version": {
const versionA = a.hostInfo?.IPNVersion?.split("-")[0] || "0";
const versionB = b.hostInfo?.IPNVersion?.split("-")[0] || "0";
const partsA = versionA.split(".").map(Number);
const partsB = versionB.split(".").map(Number);
const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) {
const segA = partsA[i] || 0;
const segB = partsB[i] || 0;
if (segA !== segB) {
comparison = segA - segB;
break;
}
}
break;
}
case "lastSeen":
if (a.online !== b.online) {
comparison = a.online ? 1 : -1;
break;
}
comparison = new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime();
break;
}
return sortDirection === "asc" ? comparison : -comparison;
});
return nodes;
}, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortDirection("asc");
}
};
return (
<>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col">
<h1 className="mb-2 text-2xl font-medium">Machines</h1>
<p>
Manage the devices connected to your Tailnet.{" "}
<Link
name="Tailscale Manage Devices Documentation"
to="https://tailscale.com/kb/1372/manage-devices"
>
Learn more
</Link>
</p>
</div>
<NewMachine
disabledKeys={loaderData.preAuth ? [] : ["pre-auth"]}
isDisabled={!loaderData.writable}
server={loaderData.publicServer ?? loaderData.server}
users={loaderData.users}
/>
</div>
<div className="mb-4 flex items-center gap-4">
<div className="relative w-64">
<Input
label="Search machines"
labelHidden
maxLength={100}
onChange={(value) => setSearchQuery(value.slice(0, 100))}
placeholder="Search by name or IP address..."
value={searchQuery}
/>
{searchQuery && (
<button
aria-label="Clear search"
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2",
"p-1 rounded-full",
"text-headplane-400 hover:text-headplane-600",
"dark:text-headplane-500 dark:hover:text-headplane-300",
"hover:bg-headplane-100 dark:hover:bg-headplane-800",
)}
onClick={() => setSearchQuery("")}
type="button"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<span className="text-headplane-500 text-sm whitespace-nowrap">
{searchQuery
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
: `${loaderData.populatedNodes.length} machines`}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] table-auto rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="px-0.5 text-left">
<th
aria-sort={
sortField === "name"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="pb-2 text-xs font-bold uppercase"
>
<button
aria-label="Sort by name"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-headplane-900 dark:hover:text-headplane-100",
)}
onClick={() => handleSort("name")}
type="button"
>
Name
{sortField === "name" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
</th>
<th
aria-sort={
sortField === "ip"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="w-1/4 pb-2"
>
<div className="flex items-center gap-x-1">
<button
aria-label="Sort by IP address"
className={cn(
"flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold",
"hover:text-headplane-900 dark:hover:text-headplane-100",
)}
onClick={() => handleSort("ip")}
type="button"
>
Addresses
{sortField === "ip" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
{loaderData.magic ? (
<Tooltip>
<Info className="h-4 w-4" />
<Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based on their name and
also at{" "}
<Code>
[name].
{loaderData.magic}
</Code>
</Tooltip.Body>
</Tooltip>
) : undefined}
</div>
</th>
{/* We only want to show the version column if there are agents */}
{loaderData.agent !== undefined ? (
<th
aria-sort={
sortField === "version"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="pb-2 text-xs font-bold uppercase"
>
<button
aria-label="Sort by version"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-headplane-900 dark:hover:text-headplane-100",
)}
onClick={() => handleSort("version")}
type="button"
>
Version
{sortField === "version" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
</th>
) : undefined}
<th
aria-sort={
sortField === "lastSeen"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="pb-2 text-xs font-bold uppercase"
>
<button
aria-label="Sort by last seen"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-headplane-900 dark:hover:text-headplane-100",
)}
onClick={() => handleSort("lastSeen")}
type="button"
>
Last Seen
{sortField === "lastSeen" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
</th>
</tr>
</thead>
<tbody
className={cn(
"divide-y divide-headplane-100 dark:divide-headplane-800 align-top",
"border-t border-headplane-100 dark:border-headplane-800",
)}
>
{filteredAndSortedNodes.length === 0 ? (
<tr>
<td
className="text-headplane-500 py-8 text-center"
colSpan={loaderData.agent !== undefined ? 5 : 4}
>
No machines found matching "{searchQuery}"
</td>
</tr>
) : (
filteredAndSortedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
isAgent={loaderData.agent ? loaderData.agent === node.nodeKey : undefined}
isDisabled={
loaderData.writable
? false // If the user has write permissions, they can edit all machines
: node.user?.providerId?.split("/").pop() !== loaderData.subject
}
key={node.id}
magic={loaderData.magic}
node={node}
users={loaderData.users}
supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange}
/>
))
)}
</tbody>
</table>
</div>
</>
);
export async function action(request: ActionFunctionArgs) {
return machineAction(request);
}
export default function Page() {
const data = useLoaderData<typeof loader>();
return (
<>
<div className="flex justify-between items-center mb-6">
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-2">Machines</h1>
<p>
Manage the devices connected to your Tailnet.{' '}
<Link
to="https://tailscale.com/kb/1372/manage-devices"
name="Tailscale Manage Devices Documentation"
>
Learn more
</Link>
</p>
</div>
<NewMachine
server={data.publicServer ?? data.server}
users={data.users}
isDisabled={!data.writable}
/>
</div>
<table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th className="uppercase text-xs font-bold pb-2">Name</th>
<th className="pb-2 w-1/4">
<div className="flex items-center gap-x-1">
<p className="uppercase text-xs font-bold">Addresses</p>
{data.magic ? (
<Tooltip>
<InfoIcon className="w-4 h-4" />
<Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based on
their name and also at{' '}
<Code>
[name].
{data.magic}
</Code>
</Tooltip.Body>
</Tooltip>
) : undefined}
</div>
</th>
{/* We only want to show the version column if there are agents */}
{data.agents !== undefined ? (
<th className="uppercase text-xs font-bold pb-2">Version</th>
) : undefined}
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{data.nodes.map((machine) => (
<MachineRow
key={machine.id}
machine={machine}
routes={data.routes.filter(
(route) => route.node.id === machine.id,
)}
users={data.users}
magic={data.magic}
// If we pass undefined, the column will not be rendered
// This is useful for when there are no agents configured
isAgent={data.agents?.includes(machine.id)}
stats={data.stats?.[machine.nodeKey]}
isDisabled={
data.writable
? false // If the user has write permissions, they can edit all machines
: machine.user.providerId?.split('/').pop() !== data.subject
}
/>
))}
</tbody>
</table>
</>
);
}
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}
+216
View File
@@ -0,0 +1,216 @@
import { useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import { Link as RemixLink } from 'react-router';
import Link from '~/components/Link';
import Select from '~/components/Select';
import TableList from '~/components/TableList';
import type { LoadContext } from '~/server';
import type { PreAuthKey, User } from '~/types';
import { send } from '~/utils/res';
import AuthKeyRow from './components/key';
import AddPreAuthKey from './dialogs/new';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const users = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
const preAuthKeys = await Promise.all(
users.users
.filter((user) => user.name?.length > 0) // Filter out any invalid users
.map((user) => {
const qp = new URLSearchParams();
qp.set('user', user.name);
return context.client.get<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('api_key')!,
);
}),
);
return {
keys: preAuthKeys.flatMap((keys) => keys.preAuthKeys),
users: users.users,
server: context.config.headscale.public_url ?? context.config.headscale.url,
};
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
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 send(
{ message: 'Missing parameters' },
{
status: 400,
},
);
}
await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey/expire',
session.get('api_key')!,
{
user: user,
key: key,
},
);
return { 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 send(
{ 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 context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.get('api_key')!,
{
user: user,
ephemeral: ephemeral === 'on',
reusable: reusable === 'on',
expiration: date.toISOString(),
aclTags: [], // TODO
},
);
return { message: 'Pre-auth key created', key };
}
}
export default function Page() {
const { keys, users, server } = useLoaderData<typeof loader>();
const [user, setUser] = useState('__headplane_all');
const [status, setStatus] = useState('active');
const filteredKeys = keys.filter((key) => {
if (user !== '__headplane_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 || key.reusable);
}
if (status === 'expired') {
return key.used || expiry < now;
}
if (status === 'reusable') {
return key.reusable;
}
if (status === 'ephemeral') {
return key.ephemeral;
}
}
return true;
});
// TODO: Fix the selects
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-2">Pre-Auth Keys</h1>
<p className="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 items-center gap-4 mt-4">
<Select
label="Filter by User"
placeholder="Select a user"
className="w-full"
defaultSelectedKey="__headplane_all"
onSelectionChange={(value) => setUser(value?.toString() ?? '')}
>
{[
<Select.Item key="__headplane_all">All</Select.Item>,
...users.map((user) => (
<Select.Item key={user.name}>{user.name}</Select.Item>
)),
]}
</Select>
<Select
label="Filter by status"
placeholder="Select a status"
className="w-full"
defaultSelectedKey="active"
onSelectionChange={(value) => setStatus(value?.toString() ?? '')}
>
<Select.Item key="all">All</Select.Item>
<Select.Item key="active">Active</Select.Item>
<Select.Item key="expired">Used/Expired</Select.Item>
<Select.Item key="reusable">Reusable</Select.Item>
<Select.Item key="ephemeral">Ephemeral</Select.Item>
</Select>
</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>
);
}

Some files were not shown because too many files have changed in this diff Show More