mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 16:18:57 +00:00
Compare commits
172 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0866ced767 | |||
| 40a2eb4186 | |||
| cc0c7fa61d | |||
| d25e5fd883 | |||
| 769674ab07 | |||
| 0276a500c7 | |||
| d6fbc3b5ad | |||
| 662c38ea44 | |||
| c827f56b6b | |||
| 753a403aed | |||
| ffce20e88a | |||
| eda8ed0b3e | |||
| 59b32c0842 | |||
| 68bd6200fc | |||
| f56d4e9d9f | |||
| 602e55a94d | |||
| 5e60ac1208 | |||
| b1da29b2fb | |||
| 0808299369 | |||
| 39d6602263 | |||
| df4f250e0f | |||
| 129e4f648b | |||
| 2a4d23d70f | |||
| f4da4c6428 | |||
| 7f5e0fe42d | |||
| 021ecabd3c | |||
| 81feb2f19a | |||
| abedf40690 | |||
| c9874966d2 | |||
| 887834efd9 | |||
| 6f2a0773ba | |||
| 8c01fcfe89 | |||
| f93acd8ef1 | |||
| a510796e38 | |||
| 69d97ea585 | |||
| 000e9326fb | |||
| 8299aac94f | |||
| 08ee0eff42 | |||
| d1f395acfd | |||
| 6bc20bd0e6 | |||
| 130b603d58 | |||
| ae0aadd83f | |||
| 6be55ec686 | |||
| fb3bc35b40 | |||
| dedec8f317 | |||
| 7eda09bae8 | |||
| ed4041073c | |||
| c5e6a6c407 | |||
| 608fcacdb1 | |||
| 3dca401837 | |||
| 785d5a663f | |||
| ece304638f | |||
| 1a2b1ad3f2 | |||
| 169a7b14d0 | |||
| 3a5ab68432 | |||
| 62863ad649 | |||
| 91fcf5233e | |||
| 8ea8c7195f | |||
| c3a50ea4f4 | |||
| 6716e5f0b0 | |||
| 346b44ec69 | |||
| faa61b0f1d | |||
| 6b63fe209f | |||
| c8507cff7c | |||
| b86f4461c0 | |||
| 77b510c927 | |||
| 5adcb8c582 | |||
| 524c5eb639 | |||
| 2894c664d3 | |||
| 5c2d08decd | |||
| f2e8c6ae4c | |||
| c3e727842a | |||
| 96345ab0a6 | |||
| fe2d7cb57a | |||
| 66c7d9a327 | |||
| f2747ada94 | |||
| 0ad578e651 | |||
| 8d1132606a | |||
| 5e332c4a5c | |||
| 69c6fc4847 | |||
| 9b09b13b5f | |||
| cf90f3dd32 | |||
| 72c1174cb3 | |||
| 63bfad77ce | |||
| 41223b89b3 | |||
| 93d5c2ed29 | |||
| 6a94e815f2 | |||
| 234020eec5 | |||
| 58cc7b742c | |||
| 259d150fc4 | |||
| 5d3fada266 | |||
| 9d046a0cf6 | |||
| 1fb084451d | |||
| d5fb8a2966 | |||
| 7b1340c93e | |||
| 16a8122f85 | |||
| 7d61ad50c4 | |||
| 103a826178 | |||
| 090dec1ca6 | |||
| 8596a56375 | |||
| 000ec620b4 | |||
| 83a69792ea | |||
| fee5f423a6 | |||
| d698cf5478 | |||
| 80c987f383 | |||
| 17d477bf0f | |||
| 5e5c7c4c7a | |||
| 2e383ddabe | |||
| 2299907932 | |||
| 3771890f98 | |||
| bf02015dc7 | |||
| 8429b19c4a | |||
| 9a5952adcb | |||
| 222ac7a279 | |||
| 6f40f9cfac | |||
| 457cbc45e6 | |||
| aac8a9ef12 | |||
| b8d22beb17 | |||
| cac64a6fbe | |||
| 5918d0e501 | |||
| 03acebb23e | |||
| 73ea35980d | |||
| 9a1051b9af | |||
| c066b3064d | |||
| 98d02bb595 | |||
| 2964ff295e | |||
| b0a3f9d5fd | |||
| 34cfee7cff | |||
| 8db323b63f | |||
| 08c25caca3 | |||
| cbbd64e91a | |||
| 48fc0f7ef3 | |||
| 23fd2bbda2 | |||
| 05837963c4 | |||
| 2a16115e69 | |||
| 5675ecdeac | |||
| 29424366a8 | |||
| c47346df52 | |||
| 92dedf51aa | |||
| 8f7a85f47d | |||
| ed1cf188dc | |||
| 03a7e51384 | |||
| 483f86c457 | |||
| a8a4d2a6f8 | |||
| eef5bd0648 | |||
| 6108de52e7 | |||
| 43e06987ad | |||
| 7741ab88bf | |||
| 3bdbfdc033 | |||
| e701e16112 | |||
| 79d83537d5 | |||
| 251c16ca48 | |||
| 134b38ceda | |||
| b8fb1fa40f | |||
| 951a96fad6 | |||
| 5b9dc7cfc6 | |||
| e36352b7f3 | |||
| 296e4d489c | |||
| c3ddac42a0 | |||
| 01f432cedc | |||
| 9c8a2c0120 | |||
| 5f6460f42f | |||
| d9314887fb | |||
| 5dd4c41291 | |||
| 45537620a6 | |||
| 21af5c4a4d | |||
| 983356611e | |||
| 41ca4c40ad | |||
| f7c9a14e92 | |||
| 6e514a9064 | |||
| 631ea15895 | |||
| 762bc6a793 |
@@ -1,7 +0,0 @@
|
||||
ROOT_API_KEY=abcdefghijklmnopqrstuvwxyz
|
||||
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
|
||||
DISABLE_API_KEY_LOGIN=true
|
||||
HEADSCALE_CONTAINER=headscale
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
CONFIG_FILE=/etc/headscale/config.yaml
|
||||
@@ -0,0 +1,2 @@
|
||||
github: tale
|
||||
ko_fi: atale
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Bug Report
|
||||
description: Report an issue with Headplane
|
||||
assignees: [tale]
|
||||
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.
|
||||
|
||||
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
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -0,0 +1,15 @@
|
||||
name: Feature Request
|
||||
description: Request a new feature or enhancement for Headplane
|
||||
assignees: [tale]
|
||||
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
|
||||
@@ -0,0 +1,33 @@
|
||||
name: Automated
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 * * 0"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: automation-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: write # Allow canceling in-progress runs
|
||||
contents: write # Read/write access to the repository
|
||||
pull-requests: write # Allow creating pull requests
|
||||
|
||||
jobs:
|
||||
flake-inputs:
|
||||
name: flake-inputs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
|
||||
- uses: DeterminateSystems/update-flake-lock@main
|
||||
with:
|
||||
pr-title: "chore: update flake.lock"
|
||||
pr-labels: |
|
||||
automated
|
||||
@@ -1,13 +1,28 @@
|
||||
name: 'Build'
|
||||
name: Build
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- ".zed/**"
|
||||
- "assets/**"
|
||||
- "docs/**"
|
||||
- "CHANGELOG.md"
|
||||
- "README.md"
|
||||
branches:
|
||||
- 'main'
|
||||
- "main"
|
||||
- "next"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: write # Allow canceling in-progress runs
|
||||
contents: read # Read access to the repository
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
native:
|
||||
name: native
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
@@ -21,7 +36,6 @@ jobs:
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
@@ -42,3 +56,21 @@ jobs:
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
nix:
|
||||
name: nix
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
|
||||
- name: Check flake inputs
|
||||
uses: DeterminateSystems/flake-checker-action@main
|
||||
|
||||
- name: Check flake outputs
|
||||
run: nix flake check --all-systems
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Pre-release (next)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: pre-release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: write # Allow canceling in-progress runs
|
||||
contents: read # Read access to the repository
|
||||
packages: write # Write access to the container registry
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Ensure the action only runs if manually dispatched or a PR on the `next` branch in the *main* repository is opened or synchronized.
|
||||
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
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=next
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
@@ -1,46 +0,0 @@
|
||||
name: Publish Docker Image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and Publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
@@ -1,17 +1,34 @@
|
||||
name: Publish Nightly Docker Image
|
||||
name: Release
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 8 * * *'
|
||||
workflow_dispatch:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: write # Allow canceling in-progress runs
|
||||
contents: read # Read access to the repository
|
||||
packages: write # Write access to the container registry
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and Publish Nightly
|
||||
docker:
|
||||
name: Docker Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
@@ -25,14 +42,6 @@ jobs:
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=edge,branch=main
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
@@ -2,4 +2,5 @@ node_modules
|
||||
/.react-router
|
||||
/.cache
|
||||
/build
|
||||
/test
|
||||
.env
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
pnpm 10.4.0
|
||||
node 22
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,89 @@
|
||||
### 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)
|
||||
- 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)
|
||||
- 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.
|
||||
- Loading the ACL page no longer spams blank updates to the Headscale database (fixes [#151](https://github.com/tale/headplane/issues/151))
|
||||
- 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)
|
||||
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
|
||||
|
||||
### 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
- Continue loosening the configuration requirements for Headscale (part of an ongoing effort).
|
||||
- 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)
|
||||
- Hotfix an issue that caused Headplane to crash if no agents are available
|
||||
|
||||
### 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))
|
||||
- OIDC auth was not using information from the `user_info` endpoint.
|
||||
- Support the picture of the user who is logged in via OIDC if available.
|
||||
- Rewrote the Agent implementation to better utilize disk space and perform better (coming soon).
|
||||
- Loosened checking for the Headscale configuration as it was too strict and required certain optional fields.
|
||||
- Deleting a node will now correctly redirect back to the nodes page (fixes [#137](https://github.com/tale/headplane/issues/137))
|
||||
- Supports connecting to Headscale via TLS and can accept a certificate file (partially fixes [#82](https://github.com/tale/headplane/issues/82))
|
||||
- Add support for running Headplane through Nix, though currently unsupported (via [#132](https://github.com/tale/headplane/pull/132))
|
||||
- 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)
|
||||
- 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)
|
||||
- Hotfixed an issue where the server bundle got reloaded on each request
|
||||
|
||||
@@ -7,6 +93,10 @@
|
||||
- Fixed the Kubernetes integration not reading the config
|
||||
|
||||
### 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.
|
||||
|
||||
- Completely redesigned the UI from the ground up for accessibility and performance.
|
||||
- Switched to a config-file setup (this introduces breaking changes, see [config.example.yaml](/config.example.yaml) for the new format).
|
||||
- If the config is read-only, the options are still visible, just disabled (fixes [#48](https://github.com/tale/headplane/issues/48))
|
||||
|
||||
+20
-8
@@ -1,3 +1,15 @@
|
||||
FROM golang:1.24 AS agent-build
|
||||
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 node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
@@ -9,15 +21,15 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
RUN pnpm prune --prod
|
||||
|
||||
FROM node:22-alpine
|
||||
RUN apk add --no-cache ca-certificates
|
||||
RUN mkdir -p /var/lib/headplane
|
||||
RUN mkdir -p /usr/libexec/headplane
|
||||
RUN mkdir -p /var/lib/headplane/agent
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build /app/build
|
||||
COPY --from=build /app/node_modules /app/node_modules
|
||||
RUN echo '{"type":"module"}' > /app/package.json
|
||||
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
CMD [ "node", "./build/headplane/server.js" ]
|
||||
COPY --from=agent-build /app/hp_agent /usr/libexec/headplane/agent
|
||||
RUN chmod +x /usr/libexec/headplane/agent
|
||||
CMD [ "node", "./build/server/index.js" ]
|
||||
|
||||
@@ -44,6 +44,18 @@ There are 2 ways to deploy Headplane:
|
||||
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
|
||||
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.
|
||||
|
||||
---
|
||||
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
|
||||
@@ -2,39 +2,34 @@ 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"
|
||||
"github.com/tale/headplane/agent/internal/config"
|
||||
"github.com/tale/headplane/agent/internal/hpagent"
|
||||
"github.com/tale/headplane/agent/internal/tsnet"
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
)
|
||||
|
||||
type Register struct {
|
||||
Type string
|
||||
ID string
|
||||
}
|
||||
|
||||
func main() {
|
||||
log := util.GetLogger()
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to load configuration: %s", err)
|
||||
log.Fatal("Failed to load config: %s", err)
|
||||
}
|
||||
|
||||
agent := tsnet.NewAgent(
|
||||
cfg.Hostname,
|
||||
cfg.TSControlURL,
|
||||
cfg.TSAuthKey,
|
||||
cfg.Debug,
|
||||
)
|
||||
log.SetDebug(cfg.Debug)
|
||||
agent := tsnet.NewAgent(cfg)
|
||||
|
||||
agent.StartAndFetchID()
|
||||
agent.Connect()
|
||||
defer agent.Shutdown()
|
||||
|
||||
ws, err := hpagent.NewSocket(
|
||||
agent,
|
||||
cfg.HPControlURL,
|
||||
cfg.HPAuthKey,
|
||||
cfg.Debug,
|
||||
)
|
||||
log.Msg(&Register{
|
||||
Type: "register",
|
||||
ID: agent.ID,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create websocket: %s", err)
|
||||
}
|
||||
|
||||
defer ws.StopListening()
|
||||
ws.StartListening()
|
||||
hpagent.FollowMaster(agent)
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package hpagent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/gorilla/websocket"
|
||||
"github.com/tale/headplane/agent/tsnet"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
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-TS-Node-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)
|
||||
}
|
||||
|
||||
return u.String(), nil
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
"os"
|
||||
)
|
||||
import "os"
|
||||
|
||||
// Config represents the configuration for the agent.
|
||||
type Config struct {
|
||||
@@ -11,17 +8,15 @@ type Config struct {
|
||||
Hostname string
|
||||
TSControlURL string
|
||||
TSAuthKey string
|
||||
HPControlURL string
|
||||
HPAuthKey string
|
||||
WorkDir string
|
||||
}
|
||||
|
||||
const (
|
||||
DebugEnv = "HP_AGENT_DEBUG"
|
||||
HostnameEnv = "HP_AGENT_HOSTNAME"
|
||||
TSControlURLEnv = "HP_AGENT_TS_SERVER"
|
||||
TSAuthKeyEnv = "HP_AGENT_TS_AUTHKEY"
|
||||
HPControlURLEnv = "HP_AGENT_HP_SERVER"
|
||||
HPAuthKeyEnv = "HP_AGENT_HP_AUTHKEY"
|
||||
DebugEnv = "HEADPLANE_AGENT_DEBUG"
|
||||
HostnameEnv = "HEADPLANE_AGENT_HOSTNAME"
|
||||
TSControlURLEnv = "HEADPLANE_AGENT_TS_SERVER"
|
||||
TSAuthKeyEnv = "HEADPLANE_AGENT_TS_AUTHKEY"
|
||||
WorkDirEnv = "HEADPLANE_AGENT_WORK_DIR"
|
||||
)
|
||||
|
||||
// Load reads the agent configuration from environment variables.
|
||||
@@ -31,8 +26,7 @@ func Load() (*Config, error) {
|
||||
Hostname: os.Getenv(HostnameEnv),
|
||||
TSControlURL: os.Getenv(TSControlURLEnv),
|
||||
TSAuthKey: os.Getenv(TSAuthKeyEnv),
|
||||
HPControlURL: os.Getenv(HPControlURLEnv),
|
||||
HPAuthKey: os.Getenv(HPAuthKeyEnv),
|
||||
WorkDir: os.Getenv(WorkDirEnv),
|
||||
}
|
||||
|
||||
if os.Getenv(DebugEnv) == "true" {
|
||||
@@ -47,9 +41,5 @@ func Load() (*Config, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := validateHPReady(c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
@@ -16,16 +16,12 @@ 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.HPAuthKey == "" {
|
||||
return fmt.Errorf("%s is required", HPAuthKeyEnv)
|
||||
if config.WorkDir == "" {
|
||||
return fmt.Errorf("%s is required", WorkDirEnv)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -38,8 +34,7 @@ func validateTSReady(config *Config) error {
|
||||
testURL = testURL[:len(testURL)-1]
|
||||
}
|
||||
|
||||
// TODO: Consequences of switching to /health (headscale only)
|
||||
testURL = fmt.Sprintf("%s/key?v=109", testURL)
|
||||
testURL = fmt.Sprintf("%s/health", testURL)
|
||||
resp, err := http.Get(testURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Failed to connect to TS control server: %s", err)
|
||||
@@ -51,23 +46,3 @@ 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
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package hpagent
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/tsnet"
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
"tailscale.com/tailcfg"
|
||||
)
|
||||
|
||||
// Represents messages from the Headplane master
|
||||
type RecvMessage struct {
|
||||
NodeIDs []string
|
||||
}
|
||||
|
||||
type SendMessage struct {
|
||||
Type string
|
||||
Data any
|
||||
}
|
||||
|
||||
// Starts listening for messages from stdin
|
||||
func FollowMaster(agent *tsnet.TSAgent) {
|
||||
log := util.GetLogger()
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
var msg RecvMessage
|
||||
err := json.Unmarshal(line, &msg)
|
||||
if err != nil {
|
||||
log.Error("Unable to unmarshal message: %s", err)
|
||||
log.Debug("Full Error: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
log.Debug("Recieved message from master: %v", line)
|
||||
|
||||
if len(msg.NodeIDs) == 0 {
|
||||
log.Debug("Message recieved had no node IDs")
|
||||
log.Debug("Full message: %s", line)
|
||||
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 := agent.GetStatusForPeer(nodeID)
|
||||
if err != nil {
|
||||
log.Error("Unable to get status for node %s: %s", nodeID, err)
|
||||
return
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
log.Debug("No status for node %s", nodeID)
|
||||
return
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
results[nodeID] = result
|
||||
mu.Unlock()
|
||||
}(nodeID)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
// Send the results back to the Headplane master
|
||||
log.Debug("Sending status back to master: %v", results)
|
||||
log.Msg(&SendMessage{
|
||||
Type: "status",
|
||||
Data: results,
|
||||
})
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
log.Fatal("Error reading from stdin: %s", err)
|
||||
}
|
||||
}
|
||||
@@ -2,9 +2,11 @@ package tsnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
"tailscale.com/tailcfg"
|
||||
"tailscale.com/types/key"
|
||||
|
||||
@@ -13,26 +15,41 @@ import (
|
||||
|
||||
// Returns the raw hostinfo for a peer based on node ID.
|
||||
func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
|
||||
log := util.GetLogger()
|
||||
|
||||
if !strings.HasPrefix(id, "nodekey:") {
|
||||
log.Debug("Node ID with missing prefix: %s", id)
|
||||
return nil, fmt.Errorf("invalid node ID: %s", id)
|
||||
}
|
||||
|
||||
if s.Debug {
|
||||
log.Printf("querying peer state for %s", id)
|
||||
}
|
||||
|
||||
log.Debug("Querying status of peer: %s", id)
|
||||
status, err := s.Lc.Status(context.Background())
|
||||
if err != nil {
|
||||
log.Debug("Failed to get status: %s", err)
|
||||
return nil, fmt.Errorf("failed to get status: %w", err)
|
||||
}
|
||||
|
||||
nodeKey, err := key.ParseNodePublicUntyped(mem.S(id[8:]))
|
||||
// We need to convert from 64 char hex to 32 byte raw.
|
||||
bytes, err := hex.DecodeString(id[8:])
|
||||
if err != nil {
|
||||
log.Debug("Failed to decode hex: %s", err)
|
||||
return nil, fmt.Errorf("failed to decode hex: %w", err)
|
||||
}
|
||||
|
||||
raw := mem.B(bytes)
|
||||
if raw.Len() != 32 {
|
||||
log.Debug("Invalid node ID length: %d", raw.Len())
|
||||
return nil, fmt.Errorf("invalid node ID length: %d", raw.Len())
|
||||
}
|
||||
|
||||
nodeKey := key.NodePublicFromRaw32(raw)
|
||||
peer := status.Peer[nodeKey]
|
||||
if peer == nil {
|
||||
// Check if we are on Self.
|
||||
if status.Self.PublicKey == nodeKey {
|
||||
peer = status.Self
|
||||
} else {
|
||||
log.Debug("Peer not found in status: %s", id)
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
@@ -40,8 +57,10 @@ func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
|
||||
ip := peer.TailscaleIPs[0].String()
|
||||
whois, err := s.Lc.WhoIs(context.Background(), ip)
|
||||
if err != nil {
|
||||
log.Debug("Failed to get whois: %s", err)
|
||||
return nil, fmt.Errorf("failed to get whois: %w", err)
|
||||
}
|
||||
|
||||
log.Debug("Got whois for peer %s: %v", id, whois)
|
||||
return &whois.Node.Hostinfo, nil
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package tsnet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/tale/headplane/agent/internal/config"
|
||||
"github.com/tale/headplane/agent/internal/util"
|
||||
"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
|
||||
}
|
||||
|
||||
// Creates a new tsnet agent and returns an instance of the server.
|
||||
func NewAgent(cfg *config.Config) *TSAgent {
|
||||
log := util.GetLogger()
|
||||
|
||||
dir, err := filepath.Abs(cfg.WorkDir)
|
||||
if err != nil {
|
||||
log.Fatal("Failed to get absolute path: %s", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
log.Fatal("Cannot create agent work directory: %s", err)
|
||||
}
|
||||
|
||||
server := &tsnet.Server{
|
||||
Dir: dir,
|
||||
Hostname: cfg.Hostname,
|
||||
ControlURL: cfg.TSControlURL,
|
||||
AuthKey: cfg.TSAuthKey,
|
||||
Logf: func(string, ...any) {}, // Disabled by default
|
||||
UserLogf: log.Info,
|
||||
}
|
||||
|
||||
if cfg.Debug {
|
||||
server.Logf = log.Debug
|
||||
}
|
||||
|
||||
return &TSAgent{server, nil, ""}
|
||||
}
|
||||
|
||||
// Starts the tsnet agent and sets the node ID.
|
||||
func (s *TSAgent) Connect() {
|
||||
log := util.GetLogger()
|
||||
|
||||
// Waits until the agent is up and running.
|
||||
status, err := s.Up(context.Background())
|
||||
if err != nil {
|
||||
log.Fatal("Failed to connect to Tailnet: %s", err)
|
||||
}
|
||||
|
||||
s.Lc, err = s.LocalClient()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to initialize local Tailscale client: %s", err)
|
||||
}
|
||||
|
||||
id, err := status.Self.PublicKey.MarshalText()
|
||||
if err != nil {
|
||||
log.Fatal("Failed to marshal public key: %s", err)
|
||||
}
|
||||
|
||||
log.Info("Connected to Tailnet (PublicKey: %s)", status.Self.PublicKey)
|
||||
s.ID = string(id)
|
||||
}
|
||||
|
||||
// Shuts down the tsnet agent.
|
||||
func (s *TSAgent) Shutdown() {
|
||||
s.Close()
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package util
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type LogLevel string
|
||||
|
||||
const (
|
||||
LevelInfo LogLevel = "info"
|
||||
LevelDebug LogLevel = "debug"
|
||||
LevelError LogLevel = "error"
|
||||
LevelFatal LogLevel = "fatal"
|
||||
LevelMsg LogLevel = "msg"
|
||||
)
|
||||
|
||||
type LogMessage struct {
|
||||
Level LogLevel
|
||||
Time string
|
||||
Message any
|
||||
}
|
||||
|
||||
type Logger struct {
|
||||
debugEnabled bool
|
||||
encoder *json.Encoder
|
||||
pool *sync.Pool
|
||||
}
|
||||
|
||||
var logger = NewLogger()
|
||||
|
||||
func GetLogger() *Logger {
|
||||
return logger
|
||||
}
|
||||
|
||||
func NewLogger() *Logger {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetEscapeHTML(false)
|
||||
|
||||
return &Logger{
|
||||
encoder: enc,
|
||||
pool: &sync.Pool{
|
||||
New: func() any {
|
||||
return &LogMessage{}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) SetDebug(enabled bool) {
|
||||
if enabled {
|
||||
l.debugEnabled = true
|
||||
l.Info("Enabling Debug logging for headplane-agent")
|
||||
l.Info("Be careful, this will spam a lot of information")
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) log(level LogLevel, format string, v ...any) {
|
||||
msg := fmt.Sprintf(format, v...)
|
||||
timestamp := time.Now().Format(time.RFC3339)
|
||||
|
||||
// Manually construct compact JSON line for performance
|
||||
line := `{"Level":"` + string(level) +
|
||||
`","Time":"` + timestamp +
|
||||
`","Message":"` + escapeString(msg) + `"}` + "\n"
|
||||
|
||||
if level == LevelError || level == LevelFatal {
|
||||
os.Stderr.WriteString(line)
|
||||
}
|
||||
|
||||
// Always write to stdout but also write to stderr for errors
|
||||
os.Stdout.WriteString(line)
|
||||
if level == LevelFatal {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Debug(format string, v ...any) {
|
||||
if l.debugEnabled {
|
||||
l.log(LevelDebug, format, v...)
|
||||
}
|
||||
}
|
||||
|
||||
func (l *Logger) Info(format string, v ...any) { l.log(LevelInfo, format, v...) }
|
||||
func (l *Logger) Error(format string, v ...any) { l.log(LevelError, format, v...) }
|
||||
func (l *Logger) Fatal(format string, v ...any) { l.log(LevelFatal, format, v...) }
|
||||
|
||||
func (l *Logger) Msg(obj any) {
|
||||
entry := l.pool.Get().(*LogMessage)
|
||||
defer l.pool.Put(entry)
|
||||
|
||||
entry.Level = LevelMsg
|
||||
entry.Time = time.Now().Format(time.RFC3339)
|
||||
entry.Message = obj
|
||||
|
||||
// Because the encoder is tied to STDOUT we get a message
|
||||
_ = l.encoder.Encode(entry)
|
||||
|
||||
// Reset the entry for reuse
|
||||
entry.Level = ""
|
||||
entry.Time = ""
|
||||
entry.Message = nil
|
||||
}
|
||||
|
||||
func escapeString(s string) string {
|
||||
replacer := strings.NewReplacer(
|
||||
`"`, `\"`,
|
||||
`\`, `\\`,
|
||||
"\n", `\n`,
|
||||
"\t", `\t`,
|
||||
)
|
||||
return replacer.Replace(s)
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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()
|
||||
}
|
||||
@@ -1,43 +1,54 @@
|
||||
import { Check, Copy } from 'lucide-react';
|
||||
import { Check, Copy, Info } 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;
|
||||
}
|
||||
|
||||
export default function Attribute({
|
||||
name,
|
||||
value,
|
||||
link,
|
||||
tooltip,
|
||||
isCopyable,
|
||||
}: AttributeProps) {
|
||||
return (
|
||||
<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
|
||||
<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,
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
{tooltip ? (
|
||||
<Tooltip>
|
||||
<Info className="size-4" />
|
||||
<Tooltip.Body>{tooltip}</Tooltip.Body>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</dt>
|
||||
<dd
|
||||
className={cn(
|
||||
'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',
|
||||
'min-w-0 px-1.5 py-1 rounded-lg border border-transparent',
|
||||
...(isCopyable
|
||||
? [
|
||||
'cursor-pointer hover:shadow-sm',
|
||||
'hover:bg-headplane-50 dark:hover:bg-headplane-800',
|
||||
'hover:border-headplane-100 dark:hover:border-headplane-700',
|
||||
]
|
||||
: []),
|
||||
)}
|
||||
>
|
||||
{isCopyable ? (
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-x-1 outline-none"
|
||||
className="flex items-center gap-1.5 relative min-w-0 w-full"
|
||||
onClick={async (event) => {
|
||||
const svgs = event.currentTarget.querySelectorAll('svg');
|
||||
for (const svg of svgs) {
|
||||
@@ -45,7 +56,7 @@ export default function Attribute({
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(value);
|
||||
toast('Copied to clipboard');
|
||||
toast(`Copied ${name} to clipboard`);
|
||||
|
||||
setTimeout(() => {
|
||||
for (const svg of svgs) {
|
||||
@@ -54,12 +65,20 @@ export default function Attribute({
|
||||
}, 1000);
|
||||
}}
|
||||
>
|
||||
<p className="truncate">{value}</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" />
|
||||
<div 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}
|
||||
</button>
|
||||
) : (
|
||||
value
|
||||
<div className="relative min-w-0 truncate" suppressHydrationWarning>
|
||||
{value}
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
|
||||
@@ -17,10 +17,10 @@ export default function Chip({
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded-full',
|
||||
'h-5 text-xs py-0.5 px-1 rounded-md text-nowrap',
|
||||
'text-headplane-700 dark:text-headplane-100',
|
||||
'bg-headplane-100 dark:bg-headplane-700',
|
||||
leftIcon || rightIcon ? 'inline-flex items-center gap-x-1' : '',
|
||||
'inline-flex items-center gap-x-1',
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -24,9 +24,7 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
|
||||
type="button"
|
||||
className="bottom-0 right-0 absolute"
|
||||
onClick={async (event) => {
|
||||
const text = Array.isArray(children)
|
||||
? children.join(' ')
|
||||
: children;
|
||||
const text = Array.isArray(children) ? children.join('') : children;
|
||||
|
||||
const svgs = event.currentTarget.querySelectorAll('svg');
|
||||
for (const svg of svgs) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { cloneElement, useRef } from 'react';
|
||||
import React, { cloneElement, useEffect, useRef } from 'react';
|
||||
import {
|
||||
type AriaDialogProps,
|
||||
type AriaModalOverlayProps,
|
||||
@@ -19,6 +19,7 @@ import IconButton, { IconButtonProps } from '~/components/IconButton';
|
||||
import Text from '~/components/Text';
|
||||
import Title from '~/components/Title';
|
||||
import cn from '~/utils/cn';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
|
||||
export interface DialogProps extends OverlayTriggerProps {
|
||||
children:
|
||||
@@ -30,6 +31,7 @@ export interface DialogProps extends OverlayTriggerProps {
|
||||
}
|
||||
|
||||
function Dialog(props: DialogProps) {
|
||||
const { pause, resume } = useLiveData();
|
||||
const state = useOverlayTriggerState(props);
|
||||
const { triggerProps, overlayProps } = useOverlayTrigger(
|
||||
{
|
||||
@@ -38,6 +40,14 @@ function Dialog(props: DialogProps) {
|
||||
state,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.isOpen) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}, [state.isOpen]);
|
||||
|
||||
if (Array.isArray(props.children)) {
|
||||
const [button, panel] = props.children;
|
||||
return (
|
||||
@@ -122,9 +132,7 @@ function Panel(props: DialogPanelProps) {
|
||||
<Button
|
||||
type="submit"
|
||||
variant={variant === 'destructive' ? 'danger' : 'heavy'}
|
||||
isDisabled={
|
||||
isDisabled || !(ref.current?.checkValidity() ?? true)
|
||||
}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
|
||||
@@ -1,16 +1,36 @@
|
||||
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';
|
||||
import Code from './Code';
|
||||
|
||||
interface Props {
|
||||
type?: 'full' | 'embedded';
|
||||
}
|
||||
|
||||
function getMessage(error: Error | unknown) {
|
||||
export function getErrorMessage(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 'An unknown error occurred';
|
||||
return {
|
||||
title: 'Unknown Error',
|
||||
message: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
let rootError = error;
|
||||
@@ -25,16 +45,19 @@ function getMessage(error: Error | unknown) {
|
||||
|
||||
// If we are aggregate, concat into a single message
|
||||
if (rootError instanceof AggregateError) {
|
||||
return rootError.errors.map((error) => error.message).join('\n');
|
||||
throw new Error('Unhandled AggregateError');
|
||||
}
|
||||
|
||||
return rootError.message;
|
||||
return {
|
||||
title: 'Error',
|
||||
message: rootError.message,
|
||||
};
|
||||
}
|
||||
|
||||
export function ErrorPopup({ type = 'full' }: Props) {
|
||||
const error = useRouteError();
|
||||
const routing = isRouteErrorResponse(error);
|
||||
const message = getMessage(error);
|
||||
const { title, message } = getErrorMessage(error);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -48,12 +71,14 @@ export function ErrorPopup({ type = 'full' }: Props) {
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-3xl mb-0">
|
||||
{routing ? error.status : 'Error'}
|
||||
{routing ? error.status : title}
|
||||
</Card.Title>
|
||||
<AlertIcon className="w-12 h-12 text-red-500" />
|
||||
</div>
|
||||
<Card.Text className="mt-4 text-lg">
|
||||
{routing ? error.statusText : <Code>{message}</Code>}
|
||||
<Card.Text
|
||||
className={cn('mt-4 text-lg', routing ? 'font-normal' : 'font-mono')}
|
||||
>
|
||||
{routing ? error.data : message}
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
+53
-24
@@ -1,42 +1,71 @@
|
||||
import { CircleX } from 'lucide-react';
|
||||
import Link from '~/components/Link';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
declare global {
|
||||
const __VERSION__: string;
|
||||
}
|
||||
|
||||
interface FooterProps {
|
||||
url: string;
|
||||
debug: boolean;
|
||||
healthy: boolean;
|
||||
}
|
||||
|
||||
export default function Footer({ url, debug }: FooterProps) {
|
||||
export default function Footer({ url, debug, healthy }: FooterProps) {
|
||||
return (
|
||||
<footer
|
||||
className={cn(
|
||||
'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',
|
||||
'fixed w-full bottom-0 left-0 z-40 h-12',
|
||||
'flex items-center justify-center',
|
||||
'bg-headplane-50 dark:bg-headplane-950',
|
||||
'dark:border-t dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
<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"
|
||||
<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')}
|
||||
>
|
||||
donating
|
||||
</Link>{' '}
|
||||
to support development.{' '}
|
||||
</p>
|
||||
<p className="container text-xs opacity-75">
|
||||
Version: {__VERSION__}
|
||||
{' — '}
|
||||
Connecting to <strong className="blur-xs hover:blur-none">{url}</strong>
|
||||
{debug && ' (Debug mode enabled)'}
|
||||
</p>
|
||||
<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-none focus:ring-2 rounded-sm',
|
||||
)}
|
||||
>
|
||||
{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>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
|
||||
+65
-25
@@ -10,12 +10,21 @@ import {
|
||||
import type { ReactNode } from 'react';
|
||||
import { NavLink, useSubmit } from 'react-router';
|
||||
import Menu from '~/components/Menu';
|
||||
import { AuthSession } from '~/server/web/sessions';
|
||||
import cn from '~/utils/cn';
|
||||
import type { SessionData } from '~/utils/sessions.server';
|
||||
|
||||
interface Props {
|
||||
configAvailable: boolean;
|
||||
user?: SessionData['user'];
|
||||
onboarding: boolean;
|
||||
user?: AuthSession['user'];
|
||||
access: {
|
||||
ui: boolean;
|
||||
machines: boolean;
|
||||
dns: boolean;
|
||||
users: boolean;
|
||||
policy: boolean;
|
||||
settings: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface LinkProps {
|
||||
@@ -91,8 +100,19 @@ export default function Header(data: Props) {
|
||||
<Link href="https://github.com/juanfont/headscale" text="Headscale" />
|
||||
{data.user ? (
|
||||
<Menu>
|
||||
<Menu.IconButton label="User">
|
||||
<CircleUser />
|
||||
<Menu.IconButton
|
||||
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"
|
||||
/>
|
||||
) : (
|
||||
<CircleUser />
|
||||
)}
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel
|
||||
onAction={(key) => {
|
||||
@@ -124,29 +144,49 @@ export default function Header(data: Props) {
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
|
||||
<TabLink
|
||||
to="/machines"
|
||||
name="Machines"
|
||||
icon={<Server className="w-5" />}
|
||||
/>
|
||||
<TabLink to="/users" name="Users" icon={<Users className="w-5" />} />
|
||||
<TabLink
|
||||
to="/acls"
|
||||
name="Access Control"
|
||||
icon={<Lock className="w-5" />}
|
||||
/>
|
||||
{data.configAvailable ? (
|
||||
<>
|
||||
<TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} />
|
||||
{data.access.ui && !data.onboarding ? (
|
||||
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
|
||||
{data.access.machines ? (
|
||||
<TabLink
|
||||
to="/settings"
|
||||
name="Settings"
|
||||
icon={<Settings className="w-5" />}
|
||||
to="/machines"
|
||||
name="Machines"
|
||||
icon={<Server className="w-5" />}
|
||||
/>
|
||||
</>
|
||||
) : undefined}
|
||||
</nav>
|
||||
) : undefined}
|
||||
{data.access.users ? (
|
||||
<TabLink
|
||||
to="/users"
|
||||
name="Users"
|
||||
icon={<Users className="w-5" />}
|
||||
/>
|
||||
) : undefined}
|
||||
{data.access.policy ? (
|
||||
<TabLink
|
||||
to="/acls"
|
||||
name="Access Control"
|
||||
icon={<Lock className="w-5" />}
|
||||
/>
|
||||
) : undefined}
|
||||
{data.configAvailable ? (
|
||||
<>
|
||||
{data.access.dns ? (
|
||||
<TabLink
|
||||
to="/dns"
|
||||
name="DNS"
|
||||
icon={<Globe2 className="w-5" />}
|
||||
/>
|
||||
) : undefined}
|
||||
{data.access.settings ? (
|
||||
<TabLink
|
||||
to="/settings"
|
||||
name="Settings"
|
||||
icon={<Settings className="w-5" />}
|
||||
/>
|
||||
) : undefined}
|
||||
</>
|
||||
) : undefined}
|
||||
</nav>
|
||||
) : undefined}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Asterisk } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import { type AriaTextFieldProps, useId, useTextField } from 'react-aria';
|
||||
import cn from '~/utils/cn';
|
||||
@@ -9,6 +10,7 @@ export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// TODO: Custom isInvalid logic for custom error messages
|
||||
export default function Input(props: InputProps) {
|
||||
const { label, labelHidden, className } = props;
|
||||
const ref = useRef<HTMLInputElement | null>(null);
|
||||
@@ -42,6 +44,9 @@ export default function Input(props: InputProps) {
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{props.isRequired && (
|
||||
<Asterisk className="inline w-3.5 text-red-500 pb-1 ml-0.5" />
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
{...inputProps}
|
||||
@@ -66,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')}
|
||||
>
|
||||
{validationErrors.join(' ')}
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+23
-6
@@ -16,6 +16,8 @@ import cn from '~/utils/cn';
|
||||
|
||||
interface MenuProps extends MenuTriggerProps {
|
||||
placement?: Placement;
|
||||
isDisabled?: boolean;
|
||||
disabledKeys?: Key[];
|
||||
children: [
|
||||
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||
React.ReactElement<MenuPanelProps>,
|
||||
@@ -23,8 +25,9 @@ interface MenuProps extends MenuTriggerProps {
|
||||
}
|
||||
|
||||
// TODO: onAction is called twice for some reason?
|
||||
// TODO: isDisabled per-prop
|
||||
function Menu(props: MenuProps) {
|
||||
const { placement = 'bottom' } = props;
|
||||
const { placement = 'bottom', isDisabled, disabledKeys = [] } = props;
|
||||
const state = useMenuTriggerState(props);
|
||||
const ref = useRef<HTMLButtonElement | null>(null);
|
||||
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
|
||||
@@ -40,6 +43,7 @@ function Menu(props: MenuProps) {
|
||||
<div>
|
||||
{cloneElement(button, {
|
||||
...menuTriggerProps,
|
||||
isDisabled: isDisabled,
|
||||
ref,
|
||||
})}
|
||||
{state.isOpen && (
|
||||
@@ -48,6 +52,7 @@ function Menu(props: MenuProps) {
|
||||
...menuProps,
|
||||
autoFocus: state.focusStrategy ?? true,
|
||||
onClose: () => state.close(),
|
||||
disabledKeys,
|
||||
})}
|
||||
</Popover>
|
||||
)}
|
||||
@@ -57,6 +62,7 @@ function Menu(props: MenuProps) {
|
||||
|
||||
interface MenuPanelProps extends AriaMenuProps<object> {
|
||||
onClose?: () => void;
|
||||
disabledKeys?: Key[];
|
||||
}
|
||||
|
||||
function Panel(props: MenuPanelProps) {
|
||||
@@ -71,7 +77,12 @@ function Panel(props: MenuPanelProps) {
|
||||
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<MenuSection key={item.key} section={item} state={state} />
|
||||
<MenuSection
|
||||
key={item.key}
|
||||
section={item}
|
||||
state={state}
|
||||
disabledKeys={props.disabledKeys}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
@@ -80,9 +91,10 @@ function Panel(props: MenuPanelProps) {
|
||||
interface MenuSectionProps<T> {
|
||||
section: Node<T>;
|
||||
state: TreeState<T>;
|
||||
disabledKeys?: Key[];
|
||||
}
|
||||
|
||||
function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
|
||||
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
|
||||
const { itemProps, groupProps } = useMenuSection({
|
||||
heading: section.rendered,
|
||||
'aria-label': section['aria-label'],
|
||||
@@ -106,7 +118,12 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
|
||||
<li {...itemProps}>
|
||||
<ul {...groupProps}>
|
||||
{[...section.childNodes].map((item) => (
|
||||
<MenuItem key={item.key} item={item} state={state} />
|
||||
<MenuItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
state={state}
|
||||
isDisabled={disabledKeys?.includes(item.key)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
@@ -117,14 +134,14 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
|
||||
interface MenuItemProps<T> {
|
||||
item: Node<T>;
|
||||
state: TreeState<T>;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
function MenuItem<T>({ item, state }: MenuItemProps<T>) {
|
||||
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
|
||||
const ref = useRef<HTMLLIElement | null>(null);
|
||||
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
|
||||
|
||||
const isFocused = state.selectionManager.focusedKey === item.key;
|
||||
const isDisabled = state.selectionManager.isDisabled(item.key);
|
||||
|
||||
return (
|
||||
<li
|
||||
|
||||
@@ -1,16 +1,45 @@
|
||||
import { CircleSlash2 } from 'lucide-react';
|
||||
import {
|
||||
CircleAlert,
|
||||
CircleSlash2,
|
||||
LucideProps,
|
||||
TriangleAlert,
|
||||
} 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 }: NoticeProps) {
|
||||
export default function Notice({
|
||||
children,
|
||||
title,
|
||||
variant,
|
||||
icon,
|
||||
}: NoticeProps) {
|
||||
return (
|
||||
<Card className="flex w-full max-w-full gap-4 font-semibold">
|
||||
<CircleSlash2 />
|
||||
{children}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
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 />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,12 +57,12 @@ export default function NumberInput(props: InputProps) {
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
id={id}
|
||||
required={props.isRequired}
|
||||
name={name}
|
||||
ref={ref}
|
||||
id={id}
|
||||
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-none"
|
||||
/>
|
||||
<input type="hidden" name={name} value={state.numberValue} />
|
||||
<IconButton
|
||||
{...decrementButtonProps}
|
||||
label="Decrement"
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
AriaTabListProps,
|
||||
AriaTabPanelProps,
|
||||
useTab,
|
||||
useTabList,
|
||||
useTabPanel,
|
||||
} from 'react-aria';
|
||||
import { Item, Node, TabListState, useTabListState } from 'react-stately';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface OptionsProps extends AriaTabListProps<object> {
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Options({ label, className, ...props }: OptionsProps) {
|
||||
const state = useTabListState(props);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { tabListProps } = useTabList(props, state, ref);
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<div
|
||||
{...tabListProps}
|
||||
ref={ref}
|
||||
className="flex items-center gap-2 overflow-x-scroll"
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<Option key={item.key} item={item} state={state} />
|
||||
))}
|
||||
</div>
|
||||
<OptionsPanel key={state.selectedItem?.key} state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OptionsOptionProps {
|
||||
item: Node<object>;
|
||||
state: TabListState<object>;
|
||||
}
|
||||
|
||||
function Option({ item, state }: OptionsOptionProps) {
|
||||
const { key, rendered } = item;
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { tabProps } = useTab({ key }, state, ref);
|
||||
return (
|
||||
<div
|
||||
{...tabProps}
|
||||
ref={ref}
|
||||
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-none focus:ring z-10',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{rendered}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OptionsPanelProps extends AriaTabPanelProps {
|
||||
state: TabListState<object>;
|
||||
}
|
||||
|
||||
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const { tabPanelProps } = useTabPanel(props, state, ref);
|
||||
return (
|
||||
<div {...tabPanelProps} ref={ref} className="w-full mt-2">
|
||||
{state.selectedItem?.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Options, { Item });
|
||||
@@ -0,0 +1,83 @@
|
||||
import React, { createContext, useContext, useRef } from 'react';
|
||||
import {
|
||||
AriaRadioGroupProps,
|
||||
AriaRadioProps,
|
||||
VisuallyHidden,
|
||||
useFocusRing,
|
||||
} from 'react-aria';
|
||||
import { RadioGroupState } from 'react-stately';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import { useRadio, useRadioGroup } from 'react-aria';
|
||||
import { useRadioGroupState } from 'react-stately';
|
||||
|
||||
interface RadioGroupProps extends AriaRadioGroupProps {
|
||||
children: React.ReactElement<RadioProps>[];
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const RadioContext = createContext<RadioGroupState | null>(null);
|
||||
|
||||
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
|
||||
const state = useRadioGroupState(props);
|
||||
const { radioGroupProps, labelProps } = useRadioGroup(
|
||||
{
|
||||
...props,
|
||||
'aria-label': label,
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
|
||||
<VisuallyHidden>
|
||||
<span {...labelProps}>{label}</span>
|
||||
</VisuallyHidden>
|
||||
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RadioProps extends AriaRadioProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Radio({ children, label, className, ...props }: RadioProps) {
|
||||
const state = useContext(RadioContext);
|
||||
const ref = useRef(null);
|
||||
const { inputProps, isSelected, isDisabled } = useRadio(
|
||||
{
|
||||
...props,
|
||||
'aria-label': label,
|
||||
},
|
||||
state!,
|
||||
ref,
|
||||
);
|
||||
const { isFocusVisible, focusProps } = useFocusRing();
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<VisuallyHidden>
|
||||
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'w-5 h-5 aspect-square rounded-full p-1 border-2',
|
||||
'border border-headplane-600 dark:border-headplane-300',
|
||||
isFocusVisible ? 'ring-4' : '',
|
||||
isDisabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
isSelected
|
||||
? 'border-[6px] border-headplane-900 dark:border-headplane-100'
|
||||
: '',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(RadioGroup, { Radio });
|
||||
@@ -78,7 +78,9 @@ function Select(props: SelectProps) {
|
||||
className={cn(
|
||||
'flex items-center justify-center p-1 rounded-lg m-1',
|
||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||
props.isDisabled
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||
)}
|
||||
>
|
||||
<ChevronDown className="p-0.5" />
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -3,9 +3,7 @@ import { createReadableStreamFromReadable } from '@react-router/node';
|
||||
import { isbot } from 'isbot';
|
||||
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
|
||||
import { renderToPipeableStream } from 'react-dom/server';
|
||||
import { EntryContext, ServerRouter } from 'react-router';
|
||||
import { hp_loadLogger } from '~/utils/log';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
import { AppLoadContext, EntryContext, ServerRouter } from 'react-router';
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
export default function handleRequest(
|
||||
@@ -13,14 +11,9 @@ export default function handleRequest(
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
loadContext: AppContext,
|
||||
loadContext: AppLoadContext,
|
||||
) {
|
||||
const { context } = loadContext;
|
||||
return new Promise((resolve, reject) => {
|
||||
// This is a promise but we don't need to wait for it to finish
|
||||
// before we start rendering the shell since it only loads once.
|
||||
hp_loadLogger(context.debug);
|
||||
|
||||
let shellRendered = false;
|
||||
const userAgent = request.headers.get('user-agent');
|
||||
|
||||
|
||||
+26
-41
@@ -1,34 +1,37 @@
|
||||
import { XCircleFillIcon } from '@primer/octicons-react';
|
||||
import { type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { ServerCrash } from 'lucide-react';
|
||||
import {
|
||||
type LoaderFunctionArgs,
|
||||
isRouteErrorResponse,
|
||||
redirect,
|
||||
useRouteError,
|
||||
} from 'react-router';
|
||||
import { Outlet, useLoaderData } from 'react-router';
|
||||
import Card from '~/components/Card';
|
||||
import { ErrorPopup, getErrorMessage } from '~/components/Error';
|
||||
import type { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import cn from '~/utils/cn';
|
||||
import { HeadscaleError, healthcheck, pull } from '~/utils/headscale';
|
||||
import log from '~/utils/log';
|
||||
import { destroySession, getSession } from '~/utils/sessions.server';
|
||||
import { useLiveData } from '~/utils/useLiveData';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
let healthy = false;
|
||||
try {
|
||||
healthy = await healthcheck();
|
||||
} catch (error) {
|
||||
log.debug('APIC', 'Healthcheck failed %o', error);
|
||||
}
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const healthy = await context.client.healthcheck();
|
||||
const session = await context.sessions.auth(request);
|
||||
|
||||
// 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) {
|
||||
// We can assert because shell ensures this is set
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
const apiKey = session.get('hsApiKey')!;
|
||||
|
||||
try {
|
||||
await pull('v1/apikey', apiKey);
|
||||
await context.client.get('v1/apikey', session.get('api_key')!);
|
||||
} catch (error) {
|
||||
if (error instanceof HeadscaleError) {
|
||||
log.debug('APIC', 'API Key validation failed %o', error);
|
||||
if (error instanceof ResponseError) {
|
||||
log.debug('api', 'API Key validation failed %o', error);
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await destroySession(session),
|
||||
'Set-Cookie': await context.sessions.destroy(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -41,33 +44,15 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
useLiveData({ interval: 3000 });
|
||||
const { healthy } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<>
|
||||
{!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() {
|
||||
return <ErrorPopup type="embedded" />;
|
||||
}
|
||||
|
||||
+143
-23
@@ -1,49 +1,169 @@
|
||||
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 { hs_getConfig } from '~/utils/config/loader';
|
||||
import { noContext } from '~/utils/log';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
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,
|
||||
}: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (!session.has('hsApiKey')) {
|
||||
}: 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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Onboarding is only a feature of the OIDC flow
|
||||
if (context.oidc && !request.url.endsWith('/onboarding')) {
|
||||
let onboarded = false;
|
||||
|
||||
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')!,
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
onboarded = false;
|
||||
}
|
||||
|
||||
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'),
|
||||
healthy: await context.client.healthcheck(),
|
||||
};
|
||||
} catch {
|
||||
// No session, so we can just return
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
}
|
||||
|
||||
const ctx = context.context;
|
||||
const { mode, config } = hs_getConfig();
|
||||
|
||||
return {
|
||||
config,
|
||||
url: ctx.headscale.public_url ?? ctx.headscale.url,
|
||||
configAvailable: mode !== 'no',
|
||||
debug: ctx.debug,
|
||||
user: session.get('user'),
|
||||
};
|
||||
}
|
||||
|
||||
export default function Shell() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header {...data} />
|
||||
<Outlet />
|
||||
{/* 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} />
|
||||
</>
|
||||
);
|
||||
|
||||
+20
-14
@@ -12,6 +12,7 @@ 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 = () => [
|
||||
@@ -29,21 +30,26 @@ export const links: LinksFunction = () => [
|
||||
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
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 (
|
||||
<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>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -6,7 +6,7 @@ export default [
|
||||
route('/healthz', 'routes/util/healthz.ts'),
|
||||
|
||||
// Authentication Routes
|
||||
route('/login', 'routes/auth/login.tsx'),
|
||||
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'),
|
||||
@@ -14,6 +14,8 @@ export default [
|
||||
// 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'),
|
||||
@@ -21,12 +23,13 @@ export default [
|
||||
]),
|
||||
|
||||
route('/users', 'routes/users/overview.tsx'),
|
||||
route('/acls', 'routes/acls/editor.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.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'),
|
||||
]),
|
||||
]),
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
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,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_policy,
|
||||
);
|
||||
if (!check) {
|
||||
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 data400('Missing `policy` in the form data.');
|
||||
}
|
||||
|
||||
try {
|
||||
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,
|
||||
policy,
|
||||
updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
// 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');
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('empty policy')) {
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: 'Policy error: Supplied policy was empty',
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, this is a Headscale error that we can just propagate.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { LoaderFunctionArgs } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
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:
|
||||
// 1. Does the user have permission to read the policy?
|
||||
// 2. Does the user have permission to write to the policy?
|
||||
// 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.
|
||||
// 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 data403('You do not have permission to read the ACL policy.');
|
||||
}
|
||||
|
||||
const flags = {
|
||||
// Can the user write to the ACL policy
|
||||
access: await context.sessions.check(request, Capabilities.write_policy),
|
||||
writable: false,
|
||||
policy: '',
|
||||
};
|
||||
|
||||
// Try to load the ACL policy from the API.
|
||||
try {
|
||||
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) {
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Otherwise, this is a Headscale error that we can just propagate.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ interface EditorProps {
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
// TODO: Remove ClientOnly
|
||||
export function Editor(props: EditorProps) {
|
||||
const [light, setLight] = useState(false);
|
||||
useEffect(() => {
|
||||
@@ -38,6 +39,8 @@ export function Editor(props: EditorProps) {
|
||||
{() => (
|
||||
<CodeMirror
|
||||
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%' }}
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { AlertIcon } from '@primer/octicons-react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
|
||||
interface Props {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function ErrorView({ message }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="max-w-full mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-xl mb-0">Error</Card.Title>
|
||||
<AlertIcon className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<Card.Text className="mt-4">
|
||||
Could not apply changes to your ACL policy due to the following error:
|
||||
<br />
|
||||
<Code>{message}</Code>
|
||||
</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
import Spinner from '~/components/Spinner';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface Props {
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { AlertIcon } from '@primer/octicons-react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
|
||||
interface Props {
|
||||
mode: 'file' | 'database';
|
||||
}
|
||||
|
||||
export function Unavailable({ mode }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="max-w-prose mt-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-xl mb-0">ACL Policy Unavailable</Card.Title>
|
||||
<AlertIcon className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<Card.Text className="mt-4">
|
||||
Unable to load a valid ACL policy configuration. This is most likely due
|
||||
to a misconfiguration in your Headscale configuration file.
|
||||
</Card.Text>
|
||||
|
||||
{mode !== 'file' ? (
|
||||
<p className="mt-4 text-sm">
|
||||
According to your configuration, the ACL policy mode is set to{' '}
|
||||
<Code>file</Code> but the ACL file is not available. Ensure that the{' '}
|
||||
<Code>policy.path</Code> is set to a valid path in your Headscale
|
||||
configuration.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-4 text-sm">
|
||||
In order to fully utilize the ACL management features of Headplane,
|
||||
please set <Code>policy.mode</Code> to either <Code>file</Code> or{' '}
|
||||
<Code>database</Code> in your Headscale configuration.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,300 +0,0 @@
|
||||
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useFetcher, useLoaderData, useRevalidator } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import Spinner from '~/components/Spinner';
|
||||
import Tabs from '~/components/Tabs';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import { HeadscaleError, pull, put } from '~/utils/headscale';
|
||||
import log from '~/utils/log';
|
||||
import { send } from '~/utils/res';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import toast from '~/utils/toast';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
import { Differ, Editor } from './components/cm.client';
|
||||
import { ErrorView } from './components/error';
|
||||
import { Unavailable } from './components/unavailable';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
|
||||
// The way policy is handled in 0.23 of Headscale and later is verbose.
|
||||
// The 2 ACL policy modes are either the database one or file one
|
||||
//
|
||||
// File: The ACL policy is readonly to the API and manually edited
|
||||
// Database: The ACL policy is read/write to the API
|
||||
//
|
||||
// To determine if we first have an ACL policy available we need to check
|
||||
// if fetching the v1/policy route gives us a 500 status code or a 200.
|
||||
//
|
||||
// 500 can mean many different things here unfortunately:
|
||||
// - In file based that means the file is not accessible
|
||||
// - In database mode this can mean that we have never set an ACL policy
|
||||
// - In database mode this can mean that the ACL policy is not available
|
||||
// - A general server error may have occurred
|
||||
//
|
||||
// Unfortunately the server errors are not very descriptive so we have to
|
||||
// do some silly guesswork here. If we are running in an integration mode
|
||||
// and have the Headscale configuration available to us, our assumptions
|
||||
// can be more accurate, otherwise we just HAVE to assume that the ACL
|
||||
// policy has never been set.
|
||||
//
|
||||
// We can do damage control by checking for write access and if we are not
|
||||
// able to PUT an ACL policy on the v1/policy route, we can already know
|
||||
// that the policy is at the very-least readonly or not available.
|
||||
const { mode, config } = hs_getConfig();
|
||||
let modeGuess = 'database'; // Assume database mode
|
||||
if (mode !== 'no') {
|
||||
modeGuess = config.policy?.mode ?? 'database';
|
||||
}
|
||||
|
||||
// Attempt to load the policy, for both the frontend and for checking
|
||||
// if we are able to write to the policy for write access
|
||||
try {
|
||||
const { policy } = await pull<{ policy: string }>(
|
||||
'v1/policy',
|
||||
session.get('hsApiKey')!,
|
||||
);
|
||||
|
||||
let write = false; // On file mode we already know it's readonly
|
||||
if (modeGuess === 'database' && policy.length > 0) {
|
||||
try {
|
||||
await put('v1/policy', session.get('hsApiKey')!, {
|
||||
policy: policy,
|
||||
});
|
||||
|
||||
write = true;
|
||||
} catch (error) {
|
||||
write = false;
|
||||
log.debug('APIC', 'Failed to write to ACL policy with error %s', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
read: true,
|
||||
write,
|
||||
mode: modeGuess,
|
||||
policy,
|
||||
};
|
||||
} catch {
|
||||
// If we are explicit on file mode then this is the end of the road
|
||||
if (modeGuess === 'file') {
|
||||
return {
|
||||
read: false,
|
||||
write: false,
|
||||
mode: modeGuess,
|
||||
policy: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Assume that we have write access otherwise?
|
||||
// This is sort of a brittle assumption to make but we don't want
|
||||
// to create a default policy if we don't have to.
|
||||
return {
|
||||
read: true,
|
||||
write: true,
|
||||
mode: modeGuess,
|
||||
policy: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (!session.has('hsApiKey')) {
|
||||
return send({ success: false, error: null }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const { acl } = (await request.json()) as { acl: string };
|
||||
const { policy } = await put<{ policy: string }>(
|
||||
'v1/policy',
|
||||
session.get('hsApiKey')!,
|
||||
{
|
||||
policy: acl,
|
||||
},
|
||||
);
|
||||
|
||||
return { success: true, policy, error: null };
|
||||
} catch (error) {
|
||||
log.debug('APIC', 'Failed to update ACL policy with error %s', error);
|
||||
|
||||
// @ts-ignore: TODO: Shut UP we know it's a string most of the time
|
||||
const text = JSON.parse(error.message);
|
||||
return send(
|
||||
{ success: false, error: text.message },
|
||||
{
|
||||
status: error instanceof HeadscaleError ? error.status : 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const fetcher = useFetcher<typeof action>();
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const [acl, setAcl] = useState(data.policy ?? '');
|
||||
const [toasted, setToasted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data || toasted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetcher.data.success) {
|
||||
toast('Updated tailnet ACL policy');
|
||||
} else {
|
||||
toast('Failed to update tailnet ACL policy');
|
||||
}
|
||||
|
||||
setToasted(true);
|
||||
if (revalidator.state === 'idle') {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
}, [fetcher.data, toasted, data.policy]);
|
||||
|
||||
// The state for if the save and discard buttons should be disabled
|
||||
// is pretty complicated to calculate and varies on different states.
|
||||
const disabled = useMemo(() => {
|
||||
if (!data.read || !data.write) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// First check our fetcher states
|
||||
if (fetcher.state === 'loading') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (revalidator.state === 'loading') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we have a failed fetcher state allow the user to try again
|
||||
if (fetcher.data?.success === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return data.policy === acl;
|
||||
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.read && !data.write ? (
|
||||
<div className="mb-4">
|
||||
<Notice>
|
||||
The ACL policy is read-only. You can view the current policy but you
|
||||
cannot make changes to it.
|
||||
<br />
|
||||
To resolve this, you need to set the ACL policy mode to database in
|
||||
your Headscale configuration.
|
||||
</Notice>
|
||||
</div>
|
||||
) : undefined}
|
||||
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
|
||||
<p className="mb-4 max-w-prose">
|
||||
The ACL file is used to define the access control rules for your
|
||||
network. You can find more information about the ACL file in the{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1018/acls"
|
||||
name="Tailscale ACL documentation"
|
||||
>
|
||||
Tailscale ACL guide
|
||||
</Link>{' '}
|
||||
and the{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/acls/"
|
||||
name="Headscale ACL documentation"
|
||||
>
|
||||
Headscale docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
{fetcher.data?.success === false ? (
|
||||
<ErrorView message={fetcher.data.error} />
|
||||
) : undefined}
|
||||
{data.read ? (
|
||||
<>
|
||||
<Tabs label="ACL Editor" className="mb-4">
|
||||
<Tabs.Item
|
||||
key="edit"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="p-1" />
|
||||
<span>Edit file</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Editor isDisabled={!data.write} value={acl} onChange={setAcl} />
|
||||
</Tabs.Item>
|
||||
<Tabs.Item
|
||||
key="diff"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="p-1" />
|
||||
<span>Preview changes</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Differ left={data?.policy ?? ''} right={acl} />
|
||||
</Tabs.Item>
|
||||
<Tabs.Item
|
||||
key="preview"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="p-1" />
|
||||
<span>Preview rules</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<Construction />
|
||||
<p className="w-1/2 text-center mt-4">
|
||||
Previewing rules is not available yet. This feature is still
|
||||
in development and is pretty complicated to implement.
|
||||
Hopefully I will be able to get to it soon.
|
||||
</p>
|
||||
</div>
|
||||
</Tabs.Item>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
setToasted(false);
|
||||
fetcher.submit(
|
||||
{
|
||||
acl,
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
setAcl(data?.policy ?? '');
|
||||
}}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Unavailable mode={data.mode as 'database' | 'file'} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
useFetcher,
|
||||
useLoaderData,
|
||||
useRevalidator,
|
||||
} from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import Tabs from '~/components/Tabs';
|
||||
import type { LoadContext } from '~/server';
|
||||
import toast from '~/utils/toast';
|
||||
import { aclAction } from './acl-action';
|
||||
import { aclLoader } from './acl-loader';
|
||||
import { Differ, Editor } from './components/cm.client';
|
||||
|
||||
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
|
||||
return aclLoader(request);
|
||||
}
|
||||
|
||||
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();
|
||||
const disabled = !access || !writable; // Disable if no permission or not writable
|
||||
|
||||
useEffect(() => {
|
||||
// Update the codePolicy when the loader data changes
|
||||
if (policy !== codePolicy) {
|
||||
setCodePolicy(policy);
|
||||
}
|
||||
}, [policy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data) {
|
||||
// No data yet, return
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetcher.data.success === true) {
|
||||
toast('Updated policy');
|
||||
revalidate();
|
||||
}
|
||||
}, [fetcher.data]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!access ? (
|
||||
<Notice title="ACL Policy restricted" variant="warning">
|
||||
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>
|
||||
) : !writable ? (
|
||||
<Notice title="Read-only ACL Policy" variant="error">
|
||||
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>acl.mode</Code> to <Code>database</Code> in your Headscale
|
||||
configuration.
|
||||
</Notice>
|
||||
) : 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
|
||||
to="https://tailscale.com/kb/1018/acls"
|
||||
name="Tailscale ACL documentation"
|
||||
>
|
||||
Tailscale ACL guide
|
||||
</Link>{' '}
|
||||
and the{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/acls/"
|
||||
name="Headscale ACL documentation"
|
||||
>
|
||||
Headscale docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
{fetcher.data?.error !== undefined ? (
|
||||
<Notice
|
||||
variant="error"
|
||||
title={fetcher.data.error.split(':')[0] ?? 'Error'}
|
||||
>
|
||||
{fetcher.data.error.split(':').slice(1).join(': ') ??
|
||||
'An unknown error occurred while trying to update the ACL policy.'}
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<Tabs label="ACL Editor" className="mb-4">
|
||||
<Tabs.Item
|
||||
key="edit"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="p-1" />
|
||||
<span>Edit file</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Editor
|
||||
isDisabled={disabled}
|
||||
value={codePolicy}
|
||||
onChange={setCodePolicy}
|
||||
/>
|
||||
</Tabs.Item>
|
||||
<Tabs.Item
|
||||
key="diff"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="p-1" />
|
||||
<span>Preview changes</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Differ left={policy} right={codePolicy} />
|
||||
</Tabs.Item>
|
||||
<Tabs.Item
|
||||
key="preview"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="p-1" />
|
||||
<span>Preview rules</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<Construction />
|
||||
<p className="w-1/2 text-center mt-4">
|
||||
Previewing rules is not available yet. This feature is still in
|
||||
development and is pretty complicated to implement. Hopefully I
|
||||
will be able to get to it soon.
|
||||
</p>
|
||||
</div>
|
||||
</Tabs.Item>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={
|
||||
disabled ||
|
||||
fetcher.state !== 'idle' ||
|
||||
codePolicy.length === 0 ||
|
||||
codePolicy === policy
|
||||
}
|
||||
onPress={() => {
|
||||
const formData = new FormData();
|
||||
formData.append('policy', codePolicy);
|
||||
fetcher.submit(formData, { method: 'PATCH' });
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={
|
||||
disabled || fetcher.state !== 'idle' || codePolicy === policy
|
||||
}
|
||||
onPress={() => {
|
||||
// Reset the editor to the original policy
|
||||
setCodePolicy(policy);
|
||||
}}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import {
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
redirect,
|
||||
} 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 { Key } from '~/types';
|
||||
import { pull } from '~/utils/headscale';
|
||||
import { noContext } from '~/utils/log';
|
||||
import { commitSession, getSession } from '~/utils/sessions.server';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (session.has('hsApiKey')) {
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
}
|
||||
|
||||
// Only set if OIDC is properly enabled anyways
|
||||
const ctx = context.context;
|
||||
if (ctx.oidc?.disable_api_key_login) {
|
||||
return redirect('/oidc/start');
|
||||
}
|
||||
|
||||
return {
|
||||
oidc: ctx.oidc?.issuer,
|
||||
apiKey: !ctx.oidc?.disable_api_key_login,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<AppContext>) {
|
||||
const formData = await request.formData();
|
||||
const oidcStart = formData.get('oidc-start');
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
|
||||
if (oidcStart) {
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
}
|
||||
|
||||
const ctx = context.context;
|
||||
if (!ctx.oidc) {
|
||||
throw new Error('An invalid OIDC configuration was provided');
|
||||
}
|
||||
|
||||
return redirect('/oidc/start');
|
||||
}
|
||||
|
||||
const apiKey = String(formData.get('api-key'));
|
||||
|
||||
// Test the API key
|
||||
try {
|
||||
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey);
|
||||
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
|
||||
if (!key) {
|
||||
throw new Error('Invalid API key');
|
||||
}
|
||||
|
||||
const expiry = new Date(key.expiration);
|
||||
const expiresIn = expiry.getTime() - Date.now();
|
||||
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
|
||||
|
||||
session.set('hsApiKey', apiKey);
|
||||
session.set('user', {
|
||||
subject: 'unknown-non-oauth',
|
||||
name: key.prefix,
|
||||
email: `${expiresDays.toString()} days`,
|
||||
});
|
||||
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session, {
|
||||
maxAge: expiresIn,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
return {
|
||||
error: 'Invalid API key',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
|
||||
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>
|
||||
{data.apiKey ? (
|
||||
<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}
|
||||
{data.oidc ? (
|
||||
<Form method="POST">
|
||||
{!data.apiKey ? (
|
||||
<Card.Text className="mb-6">
|
||||
Sign in with your authentication provider to continue. Your
|
||||
administrator has disabled API key login.
|
||||
</Card.Text>
|
||||
) : undefined}
|
||||
|
||||
<input type="hidden" name="oidc-start" value="true" />
|
||||
<Button
|
||||
className="w-full mt-2"
|
||||
variant={data.apiKey ? 'light' : 'heavy'}
|
||||
type="submit"
|
||||
>
|
||||
Single Sign-On
|
||||
</Button>
|
||||
</Form>
|
||||
) : undefined}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ActionFunctionArgs, data, redirect } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import { Key } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
|
||||
export async function loginAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
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 });
|
||||
}
|
||||
|
||||
try {
|
||||
const { apiKeys } = await context.client.get<{ apiKeys: Key[] }>(
|
||||
'v1/apikey',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
// We don't need to check for 0 API keys because this request cannot
|
||||
// be authenticated correctly without an API key
|
||||
const lookup = apiKeys.find((key) => apiKey.startsWith(key.prefix));
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
// Set the session
|
||||
const session = await context.sessions.getOrCreate(request);
|
||||
const expiresDays = Math.round(
|
||||
(expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24,
|
||||
);
|
||||
|
||||
session.set('state', 'auth');
|
||||
session.set('api_key', apiKey);
|
||||
session.set('user', {
|
||||
subject: 'unknown-non-oauth',
|
||||
name: `${lookup.prefix}...`,
|
||||
email: `expires@${expiresDays.toString()}-days`,
|
||||
});
|
||||
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.commit(session, {
|
||||
maxAge: expiry.getTime() - Date.now(),
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError) {
|
||||
// TODO: What in gods name is wrong with the headscale API?
|
||||
if (
|
||||
error.status === 401 ||
|
||||
error.status === 403 ||
|
||||
(error.status === 500 && error.response.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)',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
Form,
|
||||
LoaderFunctionArgs,
|
||||
Link as RemixLink,
|
||||
data,
|
||||
redirect,
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
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 type { LoadContext } from '~/server';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import { loginAction } from './action';
|
||||
import Logout from './logout';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (session.has('api_key')) {
|
||||
return redirect('/machines');
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const state = qp.get('s') ?? undefined;
|
||||
|
||||
// OIDC config cannot be undefined if an OIDC client is set
|
||||
// Also check if we are in a logout state and skip redirect if we are
|
||||
const ssoOnly = context.config.oidc?.disable_api_key_login;
|
||||
if (state !== 'logout' && ssoOnly) {
|
||||
// This shouldn't be possible, but still a safe sanity check
|
||||
if (!context.oidc) {
|
||||
throw data(
|
||||
'`oidc.disable_api_key_login` was set without a valid OIDC configuration',
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return redirect('/oidc/start');
|
||||
}
|
||||
|
||||
return {
|
||||
oidc: context.oidc,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(request: ActionFunctionArgs<LoadContext>) {
|
||||
return loginAction(request);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { state, oidc } = useLoaderData<typeof loader>();
|
||||
const formData = useActionData<typeof action>();
|
||||
const [params] = useSearchParams();
|
||||
const { pause } = useLiveData();
|
||||
|
||||
useEffect(() => {
|
||||
// This page does NOT need stale while revalidate logic
|
||||
pause();
|
||||
});
|
||||
|
||||
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 <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>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
|
||||
isRequired
|
||||
labelHidden
|
||||
label="API Key"
|
||||
name="api_key"
|
||||
placeholder="API Key"
|
||||
type="password"
|
||||
className="mt-8 mb-2"
|
||||
/>
|
||||
{formData?.success === false ? (
|
||||
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
|
||||
{formData.message}
|
||||
</Card.Text>
|
||||
) : undefined}
|
||||
<Button className="w-full" variant="heavy" type="submit">
|
||||
Sign In
|
||||
</Button>
|
||||
</Form>
|
||||
{oidc ? (
|
||||
<RemixLink to="/oidc/start">
|
||||
<Button variant="light" className="w-full mt-2">
|
||||
Single Sign-On
|
||||
</Button>
|
||||
</RemixLink>
|
||||
) : undefined}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,28 @@
|
||||
import { type ActionFunctionArgs, redirect } from 'react-router';
|
||||
import { destroySession, getSession } from '~/utils/sessions.server';
|
||||
import type { LoadContext } from '~/server';
|
||||
|
||||
export async function loader() {
|
||||
return redirect('/machines');
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
return redirect('/login', {
|
||||
export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
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
|
||||
// with a logout state to prevent auto login again.
|
||||
const url = context.config.oidc?.disable_api_key_login
|
||||
? '/login?s=logout'
|
||||
: '/login';
|
||||
|
||||
return redirect(url, {
|
||||
headers: {
|
||||
'Set-Cookie': await destroySession(session),
|
||||
'Set-Cookie': await context.sessions.destroy(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,70 +1,61 @@
|
||||
import { type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { noContext } from '~/utils/log';
|
||||
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 { commitSession, getSession } from '~/utils/sessions.server';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<AppContext>) {
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
if (!context.oidc) {
|
||||
throw new Error('OIDC is not enabled');
|
||||
}
|
||||
|
||||
// Check if we have 0 query parameters
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.toString().length === 0) {
|
||||
return redirect('/machines');
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (session.has('hsApiKey')) {
|
||||
return redirect('/machines');
|
||||
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
|
||||
if (session.get('state') !== 'flow') {
|
||||
return redirect('/login'); // Haven't started an OIDC flow
|
||||
}
|
||||
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
}
|
||||
|
||||
const { oidc } = context.context;
|
||||
if (!oidc) {
|
||||
throw new Error('An invalid OIDC configuration was provided');
|
||||
}
|
||||
|
||||
const codeVerifier = session.get('oidc_code_verif');
|
||||
const state = session.get('oidc_state');
|
||||
const nonce = session.get('oidc_nonce');
|
||||
const redirectUri = session.get('oidc_redirect_uri');
|
||||
|
||||
if (!codeVerifier || !state || !nonce || !redirectUri) {
|
||||
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 });
|
||||
}
|
||||
|
||||
// Reconstruct the redirect URI using the query parameters
|
||||
// and the one we saved in the session
|
||||
const flowRedirectUri = new URL(redirectUri);
|
||||
const flowRedirectUri = new URL(redirect_uri);
|
||||
flowRedirectUri.search = url.search;
|
||||
|
||||
const flowOptions = {
|
||||
redirect_uri: flowRedirectUri.toString(),
|
||||
codeVerifier,
|
||||
code_verifier,
|
||||
state,
|
||||
nonce: nonce === '<none>' ? undefined : nonce,
|
||||
};
|
||||
|
||||
try {
|
||||
const user = await finishAuthFlow(oidc, flowOptions);
|
||||
session.set('user', user);
|
||||
session.unset('oidc_code_verif');
|
||||
session.unset('oidc_state');
|
||||
session.unset('oidc_nonce');
|
||||
const user = await finishAuthFlow(context.oidc, flowOptions);
|
||||
session.unset('oidc');
|
||||
const userSession = session as Session<AuthSession>;
|
||||
|
||||
// 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.
|
||||
session.set('hsApiKey', oidc.headscale_api_key);
|
||||
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 commitSession(session),
|
||||
'Set-Cookie': await context.sessions.commit(userSession),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,38 +1,42 @@
|
||||
import { type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { noContext } from '~/utils/log';
|
||||
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 { commitSession, getSession } from '~/utils/sessions.server';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (session.has('hsApiKey')) {
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
|
||||
if ((session as Session<AuthSession>).has('api_key')) {
|
||||
return redirect('/machines');
|
||||
}
|
||||
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
if (!context.oidc) {
|
||||
throw new Error('OIDC is not enabled');
|
||||
}
|
||||
|
||||
const { oidc } = context.context;
|
||||
if (!oidc) {
|
||||
throw new Error('An invalid OIDC configuration was provided');
|
||||
}
|
||||
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 redirectUri = oidc.redirect_uri ?? getRedirectUri(request);
|
||||
const data = await beginAuthFlow(oidc, redirectUri);
|
||||
session.set('oidc_code_verif', data.codeVerifier);
|
||||
session.set('oidc_state', data.state);
|
||||
session.set('oidc_nonce', data.nonce);
|
||||
session.set('oidc_redirect_uri', redirectUri);
|
||||
session.set('state', 'flow');
|
||||
session.set('oidc', {
|
||||
state: data.state,
|
||||
nonce: data.nonce,
|
||||
code_verifier: data.codeVerifier,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
return redirect(data.url, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
'Set-Cookie': await context.sessions.commit(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function ManageRecords({ records, isDisabled }: Props) {
|
||||
<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> records are supported.{' '}
|
||||
only <Code>A</Code> and <Code>AAAA</Code> records are supported.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/dns"
|
||||
name="Headscale DNS Records documentation"
|
||||
@@ -34,19 +34,19 @@ export default function ManageRecords({ records, isDisabled }: Props) {
|
||||
) : (
|
||||
records.map((record, index) => (
|
||||
<TableList.Item key={`${record.name}-${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>
|
||||
<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="grid grid-cols-2 gap-2 w-full">
|
||||
<p className="font-mono text-sm">{record.name}</p>
|
||||
<p className="font-mono text-sm">{record.value}</p>
|
||||
</div>
|
||||
<p className="font-mono text-sm">{record.value}</p>
|
||||
</div>
|
||||
<Form method="POST">
|
||||
<input type="hidden" name="action_id" value="remove_record" />
|
||||
|
||||
@@ -2,12 +2,14 @@ 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' | string; value: string }[];
|
||||
records: { name: string; type: 'A' | 'AAAA' | 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('');
|
||||
|
||||
@@ -22,14 +24,30 @@ export default function AddRecord({ records }: Props) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>Add DNS record</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Panel
|
||||
onSubmit={() => {
|
||||
setName('');
|
||||
setIp('');
|
||||
}}
|
||||
>
|
||||
<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" />
|
||||
<input type="hidden" name="record_type" value="A" />
|
||||
<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
|
||||
isRequired
|
||||
label="Domain"
|
||||
@@ -41,7 +59,9 @@ export default function AddRecord({ records }: Props) {
|
||||
<Input
|
||||
isRequired
|
||||
label="IP Address"
|
||||
placeholder="101.101.101.101"
|
||||
placeholder={
|
||||
type === 'AAAA' ? '2001:db8::ff00:42:8329' : '101.101.101.101'
|
||||
}
|
||||
name="record_value"
|
||||
onChange={setIp}
|
||||
isInvalid={isDuplicate}
|
||||
|
||||
@@ -1,16 +1,21 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { hs_getConfig, hs_patchConfig } from '~/utils/config/loader';
|
||||
import { hp_getIntegration } from '~/utils/integration/loader';
|
||||
import { auth } from '~/utils/sessions.server';
|
||||
import { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
|
||||
export async function dnsAction({ request }: ActionFunctionArgs) {
|
||||
const session = await auth(request);
|
||||
if (!session) {
|
||||
return data({ success: false }, 401);
|
||||
export async function dnsAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_network,
|
||||
);
|
||||
|
||||
if (!check) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
const { mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
if (!context.hs.writable()) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
@@ -22,59 +27,60 @@ export async function dnsAction({ request }: ActionFunctionArgs) {
|
||||
|
||||
switch (action) {
|
||||
case 'rename_tailnet':
|
||||
return renameTailnet(formData);
|
||||
return renameTailnet(formData, context);
|
||||
case 'toggle_magic':
|
||||
return toggleMagic(formData);
|
||||
return toggleMagic(formData, context);
|
||||
case 'remove_ns':
|
||||
return removeNs(formData);
|
||||
return removeNs(formData, context);
|
||||
case 'add_ns':
|
||||
return addNs(formData);
|
||||
return addNs(formData, context);
|
||||
case 'remove_domain':
|
||||
return removeDomain(formData);
|
||||
return removeDomain(formData, context);
|
||||
case 'add_domain':
|
||||
return addDomain(formData);
|
||||
return addDomain(formData, context);
|
||||
case 'remove_record':
|
||||
return removeRecord(formData);
|
||||
return removeRecord(formData, context);
|
||||
case 'add_record':
|
||||
return addRecord(formData);
|
||||
return addRecord(formData, context);
|
||||
default:
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameTailnet(formData: FormData) {
|
||||
async function renameTailnet(formData: FormData, context: LoadContext) {
|
||||
const newName = formData.get('new_name')?.toString();
|
||||
if (!newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.base_domain',
|
||||
value: newName,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function toggleMagic(formData: FormData) {
|
||||
async function toggleMagic(formData: FormData, context: LoadContext) {
|
||||
const newState = formData.get('new_state')?.toString();
|
||||
if (!newState) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.magic_dns',
|
||||
value: newState === 'enabled',
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function removeNs(formData: FormData) {
|
||||
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();
|
||||
|
||||
@@ -82,15 +88,10 @@ async function removeNs(formData: FormData) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
if (splitName === 'global') {
|
||||
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.nameservers.global',
|
||||
value: servers,
|
||||
@@ -100,7 +101,7 @@ async function removeNs(formData: FormData) {
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName].filter((i) => i !== ns);
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers,
|
||||
@@ -108,10 +109,11 @@ async function removeNs(formData: FormData) {
|
||||
]);
|
||||
}
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function addNs(formData: FormData) {
|
||||
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();
|
||||
|
||||
@@ -119,16 +121,11 @@ async function addNs(formData: FormData) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
if (splitName === 'global') {
|
||||
const servers = config.dns.nameservers.global;
|
||||
servers.push(ns);
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.nameservers.global',
|
||||
value: servers,
|
||||
@@ -139,7 +136,7 @@ async function addNs(formData: FormData) {
|
||||
const servers = splits[splitName] ?? [];
|
||||
servers.push(ns);
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers,
|
||||
@@ -147,57 +144,49 @@ async function addNs(formData: FormData) {
|
||||
]);
|
||||
}
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function removeDomain(formData: FormData) {
|
||||
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 { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
const domains = config.dns.search_domains.filter((i) => i !== domain);
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.search_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function addDomain(formData: FormData) {
|
||||
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 { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
const domains = config.dns.search_domains;
|
||||
domains.push(domain);
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.search_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function removeRecord(formData: FormData) {
|
||||
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();
|
||||
|
||||
@@ -205,26 +194,22 @@ async function removeRecord(formData: FormData) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
// Value is not needed for removal
|
||||
const restart = await context.hs.removeDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: '',
|
||||
});
|
||||
|
||||
if (!restart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const records = config.dns.extra_records.filter(
|
||||
(i) => i.name !== recordName || i.type !== recordType,
|
||||
);
|
||||
|
||||
await hs_patchConfig([
|
||||
{
|
||||
path: 'dns.extra_records',
|
||||
value: records,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function addRecord(formData: FormData) {
|
||||
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();
|
||||
@@ -233,20 +218,15 @@ async function addRecord(formData: FormData) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
const restart = await context.hs.addDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: recordValue,
|
||||
});
|
||||
|
||||
if (!restart) {
|
||||
return;
|
||||
}
|
||||
|
||||
const records = config.dns.extra_records;
|
||||
records.push({ name: recordName, type: recordType, value: recordValue });
|
||||
|
||||
await hs_patchConfig([
|
||||
{
|
||||
path: 'dns.extra_records',
|
||||
value: records,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import type { ActionFunctionArgs } from 'react-router';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import Notice from '~/components/Notice';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import ManageDomains from './components/manage-domains';
|
||||
import ManageNS from './components/manage-ns';
|
||||
import ManageRecords from './components/manage-records';
|
||||
@@ -11,12 +12,31 @@ import ToggleMagic from './components/toggle-magic';
|
||||
import { dnsAction } from './dns-actions';
|
||||
|
||||
// We do not want to expose every config value
|
||||
export async function loader() {
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode === 'no') {
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
if (!context.hs.readable()) {
|
||||
throw new Error('No configuration is available');
|
||||
}
|
||||
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.read_network,
|
||||
);
|
||||
if (!check) {
|
||||
// Not authorized to view this page
|
||||
throw new Error(
|
||||
'You do not have permission to view this page. Please contact your administrator.',
|
||||
);
|
||||
}
|
||||
|
||||
const writablePermission = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_network,
|
||||
);
|
||||
|
||||
const config = context.hs.c!;
|
||||
const dns = {
|
||||
prefixes: config.prefixes,
|
||||
magicDns: config.dns.magic_dns,
|
||||
@@ -24,12 +44,13 @@ export async function loader() {
|
||||
nameservers: config.dns.nameservers.global,
|
||||
splitDns: config.dns.nameservers.split,
|
||||
searchDomains: config.dns.search_domains,
|
||||
extraRecords: config.dns.extra_records,
|
||||
extraRecords: context.hs.d,
|
||||
};
|
||||
|
||||
return {
|
||||
...dns,
|
||||
mode,
|
||||
access: writablePermission,
|
||||
writable: context.hs.writable(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,16 +67,22 @@ export default function Page() {
|
||||
}
|
||||
|
||||
allNs.global = data.nameservers;
|
||||
const isDisabled = data.mode !== 'rw';
|
||||
const isDisabled = data.access === false || data.writable === false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-16 max-w-screen-lg">
|
||||
{data.mode === 'rw' ? undefined : (
|
||||
{data.writable ? undefined : (
|
||||
<Notice>
|
||||
The Headscale configuration is read-only. You cannot make changes to
|
||||
the configuration
|
||||
</Notice>
|
||||
)}
|
||||
{data.access ? undefined : (
|
||||
<Notice>
|
||||
Your permissions do not allow you to modify the DNS settings for this
|
||||
tailnet.
|
||||
</Notice>
|
||||
)}
|
||||
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
|
||||
<ManageNS nameservers={allNs} isDisabled={isDisabled} />
|
||||
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
import type { ActionFunctionArgs } from 'react-router';
|
||||
import { del, post } from '~/utils/headscale';
|
||||
import log from '~/utils/log';
|
||||
import { send } from '~/utils/res';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
|
||||
export async function menuAction(request: ActionFunctionArgs['request']) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (!session.has('hsApiKey')) {
|
||||
return send(
|
||||
{ message: 'Unauthorized' },
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const data = await request.formData();
|
||||
if (!data.has('_method') || !data.has('id')) {
|
||||
return send(
|
||||
{ message: 'No method or ID provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const id = String(data.get('id'));
|
||||
const method = String(data.get('_method'));
|
||||
|
||||
switch (method) {
|
||||
case 'delete': {
|
||||
await del(`v1/node/${id}`, session.get('hsApiKey')!);
|
||||
return { message: 'Machine removed' };
|
||||
}
|
||||
|
||||
case 'expire': {
|
||||
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!);
|
||||
return { message: 'Machine expired' };
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
if (!data.has('name')) {
|
||||
return send(
|
||||
{ message: 'No name provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const name = String(data.get('name'));
|
||||
|
||||
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!);
|
||||
return { message: 'Machine renamed' };
|
||||
}
|
||||
|
||||
case 'routes': {
|
||||
if (!data.has('route') || !data.has('enabled')) {
|
||||
return send(
|
||||
{ message: 'No route or enabled provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const route = String(data.get('route'));
|
||||
const enabled = data.get('enabled') === 'true';
|
||||
const postfix = enabled ? 'enable' : 'disable';
|
||||
|
||||
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
|
||||
return { message: 'Route updated' };
|
||||
}
|
||||
|
||||
case 'exit-node': {
|
||||
if (!data.has('routes') || !data.has('enabled')) {
|
||||
return send(
|
||||
{ message: 'No route or enabled provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const routes = data.get('routes')?.toString().split(',') ?? [];
|
||||
const enabled = data.get('enabled') === 'true';
|
||||
const postfix = enabled ? 'enable' : 'disable';
|
||||
|
||||
await Promise.all(
|
||||
routes.map(async (route) => {
|
||||
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
|
||||
}),
|
||||
);
|
||||
|
||||
return { message: 'Exit node updated' };
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
if (!data.has('to')) {
|
||||
return send(
|
||||
{ message: 'No destination provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const to = String(data.get('to'));
|
||||
|
||||
try {
|
||||
await post(`v1/node/${id}/user`, session.get('hsApiKey')!, {
|
||||
user: to,
|
||||
});
|
||||
|
||||
return { message: `Moved node ${id} to ${to}` };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return send(
|
||||
{ message: `Failed to move node ${id} to ${to}` },
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case 'tags': {
|
||||
const tags =
|
||||
data
|
||||
.get('tags')
|
||||
?.toString()
|
||||
.split(',')
|
||||
.filter((tag) => tag.trim() !== '') ?? [];
|
||||
|
||||
try {
|
||||
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
|
||||
tags,
|
||||
});
|
||||
|
||||
return { message: 'Tags updated' };
|
||||
} catch (error) {
|
||||
log.debug('APIC', 'Failed to update tags: %s', error);
|
||||
return send(
|
||||
{ message: 'Failed to update tags' },
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case 'register': {
|
||||
const key = data.get('mkey')?.toString();
|
||||
const user = data.get('user')?.toString();
|
||||
|
||||
if (!key) {
|
||||
return send(
|
||||
{ message: 'No machine key provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return send(
|
||||
{ message: 'No user provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const qp = new URLSearchParams();
|
||||
qp.append('user', user);
|
||||
qp.append('key', key);
|
||||
|
||||
const url = `v1/node/register?${qp.toString()}`;
|
||||
await post(url, session.get('hsApiKey')!, {
|
||||
user,
|
||||
key,
|
||||
});
|
||||
|
||||
return {
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
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 { User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import * as hinfo from '~/utils/host-info';
|
||||
|
||||
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 { PopulatedNode } from '~/utils/node-info';
|
||||
import toast from '~/utils/toast';
|
||||
import MenuOptions from './menu';
|
||||
|
||||
interface Props {
|
||||
node: PopulatedNode;
|
||||
users: User[];
|
||||
isAgent?: boolean;
|
||||
magic?: string;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function MachineRow({
|
||||
node,
|
||||
users,
|
||||
isAgent,
|
||||
magic,
|
||||
isDisabled,
|
||||
}: Props) {
|
||||
const uiTags = useMemo(() => {
|
||||
const tags = uiTagsForNode(node, isAgent);
|
||||
return tags;
|
||||
}, [node, isAgent]);
|
||||
|
||||
const ipOptions = useMemo(() => {
|
||||
if (magic) {
|
||||
return [...node.ipAddresses, `${node.givenName}.${magic}`];
|
||||
}
|
||||
|
||||
return node.ipAddresses;
|
||||
}, [magic, node.ipAddresses]);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={node.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/${node.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',
|
||||
)}
|
||||
>
|
||||
{node.givenName}
|
||||
</p>
|
||||
<p className="text-sm opacity-50">{node.user.name}</p>
|
||||
<div className="flex gap-1 flex-wrap mt-1.5">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{node.validTags.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">
|
||||
<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">
|
||||
{node.hostInfo !== undefined ? (
|
||||
<>
|
||||
<p className="leading-snug">
|
||||
{hinfo.getTSVersion(node.hostInfo)}
|
||||
</p>
|
||||
<p className="text-sm opacity-50 max-w-48 truncate">
|
||||
{hinfo.getOSInfo(node.hostInfo)}
|
||||
</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={node.online && !node.expired}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<p suppressHydrationWarning>
|
||||
{node.online && !node.expired
|
||||
? 'Connected'
|
||||
: new Date(node.lastSeen).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions
|
||||
node={node}
|
||||
users={users}
|
||||
magic={magic}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</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 (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 key={tag} isEnabled={tag === 'exit-approved'} />;
|
||||
|
||||
case 'subnet-approved':
|
||||
case 'subnet-waiting':
|
||||
return <SubnetTag key={tag} isEnabled={tag === 'subnet-approved'} />;
|
||||
|
||||
case 'expired':
|
||||
case 'no-expiry':
|
||||
return (
|
||||
<ExpiryTag
|
||||
key={tag}
|
||||
variant={tag}
|
||||
expiry={node.expiry ?? undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'headplane-agent':
|
||||
return <HeadplaneAgentTag />;
|
||||
|
||||
default:
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,195 +0,0 @@
|
||||
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 toast from '~/utils/toast';
|
||||
import MenuOptions from './menu';
|
||||
|
||||
interface Props {
|
||||
machine: Machine;
|
||||
routes: Route[];
|
||||
users: User[];
|
||||
magic?: string;
|
||||
stats?: HostInfo;
|
||||
}
|
||||
|
||||
export default function MachineRow({
|
||||
machine,
|
||||
routes,
|
||||
magic,
|
||||
users,
|
||||
stats,
|
||||
}: Props) {
|
||||
const expired =
|
||||
machine.expiry === '0001-01-01 00:00:00' ||
|
||||
machine.expiry === '0001-01-01T00:00:00Z' ||
|
||||
machine.expiry === null
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now();
|
||||
|
||||
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
|
||||
|
||||
if (expired) {
|
||||
tags.unshift('Expired');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
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>
|
||||
{/**
|
||||
<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>
|
||||
**/}
|
||||
<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>
|
||||
{machine.online && !expired
|
||||
? 'Connected'
|
||||
: new Date(machine.lastSeen).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
users={users}
|
||||
magic={magic}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Cog, Ellipsis } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import Menu from '~/components/Menu';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import type { 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';
|
||||
@@ -11,36 +12,28 @@ import Routes from '../dialogs/routes';
|
||||
import Tags from '../dialogs/tags';
|
||||
|
||||
interface MenuProps {
|
||||
machine: Machine;
|
||||
routes: Route[];
|
||||
node: PopulatedNode;
|
||||
users: User[];
|
||||
magic?: string;
|
||||
isFullButton?: boolean;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
||||
|
||||
export default function MachineMenu({
|
||||
machine,
|
||||
routes,
|
||||
node,
|
||||
magic,
|
||||
users,
|
||||
isFullButton,
|
||||
isDisabled,
|
||||
}: MenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
|
||||
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 (
|
||||
<>
|
||||
{modal === 'remove' && (
|
||||
<Delete
|
||||
machine={machine}
|
||||
machine={node}
|
||||
isOpen={modal === 'remove'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
@@ -49,7 +42,7 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'move' && (
|
||||
<Move
|
||||
machine={machine}
|
||||
machine={node}
|
||||
users={users}
|
||||
isOpen={modal === 'move'}
|
||||
setIsOpen={(isOpen) => {
|
||||
@@ -59,7 +52,7 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'rename' && (
|
||||
<Rename
|
||||
machine={machine}
|
||||
machine={node}
|
||||
magic={magic}
|
||||
isOpen={modal === 'rename'}
|
||||
setIsOpen={(isOpen) => {
|
||||
@@ -69,8 +62,7 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'routes' && (
|
||||
<Routes
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
node={node}
|
||||
isOpen={modal === 'routes'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
@@ -79,16 +71,16 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'tags' && (
|
||||
<Tags
|
||||
machine={machine}
|
||||
machine={node}
|
||||
isOpen={modal === 'tags'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{expired && modal === 'expire' ? undefined : (
|
||||
{node.expired && modal === 'expire' ? undefined : (
|
||||
<Expire
|
||||
machine={machine}
|
||||
machine={node}
|
||||
isOpen={modal === 'expire'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
@@ -96,7 +88,7 @@ export default function MachineMenu({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu>
|
||||
<Menu isDisabled={isDisabled}>
|
||||
{isFullButton ? (
|
||||
<Menu.Button className="flex items-center gap-x-2">
|
||||
<Cog className="h-5" />
|
||||
@@ -114,7 +106,10 @@ export default function MachineMenu({
|
||||
<Ellipsis className="h-5" />
|
||||
</Menu.IconButton>
|
||||
)}
|
||||
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
|
||||
<Menu.Panel
|
||||
onAction={(key) => setModal(key as Modal)}
|
||||
disabledKeys={node.expired ? ['expire'] : []}
|
||||
>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="rename">Edit machine name</Menu.Item>
|
||||
<Menu.Item key="routes">Edit route settings</Menu.Item>
|
||||
@@ -122,13 +117,9 @@ export default function MachineMenu({
|
||||
<Menu.Item key="move">Change owner</Menu.Item>
|
||||
</Menu.Section>
|
||||
<Menu.Section>
|
||||
{expired ? (
|
||||
<></>
|
||||
) : (
|
||||
<Menu.Item key="expire" textValue="Expire">
|
||||
<p className="text-red-500 dark:text-red-400">Expire</p>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<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>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useNavigate } from 'react-router';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import type { Machine } from '~/types';
|
||||
|
||||
@@ -8,16 +9,21 @@ interface DeleteProps {
|
||||
}
|
||||
|
||||
export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel variant="destructive">
|
||||
<Dialog.Panel
|
||||
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 type="hidden" name="_method" value="delete" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<input type="hidden" name="action_id" value="delete" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -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 type="hidden" name="_method" value="expire" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<input type="hidden" name="action_id" value="expire" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Key, useState } from 'react';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Select from '~/components/Select';
|
||||
import type { Machine, User } from '~/types';
|
||||
@@ -10,6 +11,8 @@ interface MoveProps {
|
||||
}
|
||||
|
||||
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
||||
const [userId, setUserId] = useState<Key | null>(null);
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
@@ -17,13 +20,18 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
||||
<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} />
|
||||
<input type="hidden" name="action_id" value="reassign" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<input type="hidden" name="user_id" value={userId?.toString()} />
|
||||
<Select
|
||||
isRequired
|
||||
label="Owner"
|
||||
name="to"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
defaultSelectedKey={machine.user.id}
|
||||
onSelectionChange={(key) => {
|
||||
setUserId(key);
|
||||
}}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Computer, KeySquare } from 'lucide-react';
|
||||
import { Computer, FileKey2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
@@ -8,12 +8,14 @@ import Menu from '~/components/Menu';
|
||||
import Select from '~/components/Select';
|
||||
import type { User } from '~/types';
|
||||
|
||||
export interface NewProps {
|
||||
export interface NewMachineProps {
|
||||
server: string;
|
||||
users: User[];
|
||||
isDisabled?: boolean;
|
||||
disabledKeys?: string[];
|
||||
}
|
||||
|
||||
export default function New(data: NewProps) {
|
||||
export default function NewMachine(data: NewMachineProps) {
|
||||
const [pushDialog, setPushDialog] = useState(false);
|
||||
const [mkey, setMkey] = useState('');
|
||||
const navigate = useNavigate();
|
||||
@@ -21,24 +23,20 @@ export default function New(data: NewProps) {
|
||||
return (
|
||||
<>
|
||||
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
|
||||
<Dialog.Panel isDisabled={!mkey.trim().startsWith('mkey:')}>
|
||||
<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.
|
||||
<Code isCopyable>tailscale up --login-server={data.server}</Code> on
|
||||
your device.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="register" />
|
||||
<input type="hidden" name="id" value="_" />
|
||||
<input type="hidden" name="action_id" value="register" />
|
||||
<Input
|
||||
isRequired
|
||||
label="Machine Key"
|
||||
placeholder="mkey:ff....."
|
||||
placeholder="AbCd..."
|
||||
validationBehavior="native"
|
||||
name="mkey"
|
||||
name="register_key"
|
||||
onChange={setMkey}
|
||||
/>
|
||||
<Select
|
||||
@@ -53,7 +51,7 @@ export default function New(data: NewProps) {
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
<Menu>
|
||||
<Menu isDisabled={data.isDisabled} disabledKeys={data.disabledKeys}>
|
||||
<Menu.Button variant="heavy">Add Device</Menu.Button>
|
||||
<Menu.Panel
|
||||
onAction={(key) => {
|
||||
@@ -76,7 +74,7 @@ export default function New(data: NewProps) {
|
||||
</Menu.Item>
|
||||
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<KeySquare className="w-4" />
|
||||
<FileKey2 className="w-4" />
|
||||
Generate Pre-auth Key
|
||||
</div>
|
||||
</Menu.Item>
|
||||
|
||||
@@ -27,14 +27,45 @@ export default function Rename({
|
||||
This name is shown in the admin panel, in Tailscale clients, and used
|
||||
when generating MagicDNS names.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="rename" />
|
||||
<input type="hidden" name="id" value={machine.id} />
|
||||
<input type="hidden" name="action_id" value="rename" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<Input
|
||||
isRequired
|
||||
label="Machine name"
|
||||
placeholder="Machine name"
|
||||
validationBehavior="native"
|
||||
name="name"
|
||||
defaultValue={machine.givenName}
|
||||
onChange={setName}
|
||||
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';
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{magic ? (
|
||||
name.length > 0 && name !== machine.givenName ? (
|
||||
|
||||
@@ -1,55 +1,30 @@
|
||||
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 type { Machine, Route } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { PopulatedNode } from '~/utils/node-info';
|
||||
|
||||
interface RoutesProps {
|
||||
machine: Machine;
|
||||
routes: Route[];
|
||||
node: PopulatedNode;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
// TODO: Support deleting routes
|
||||
export default function Routes({
|
||||
machine,
|
||||
routes,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: RoutesProps) {
|
||||
export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
|
||||
const fetcher = useFetcher();
|
||||
|
||||
// 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]);
|
||||
const subnets = [
|
||||
...node.customRouting.subnetApprovedRoutes,
|
||||
...node.customRouting.subnetWaitingRoutes,
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel variant="unactionable">
|
||||
<Dialog.Title>Edit route settings of {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Title>Edit route settings of {node.givenName}</Dialog.Title>
|
||||
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
|
||||
<Dialog.Text>
|
||||
Connect to devices you can't install Tailscale on by advertising
|
||||
@@ -62,7 +37,7 @@ export default function Routes({
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
<TableList className="mt-4">
|
||||
{subnet.length === 0 ? (
|
||||
{subnets.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<RouteOff />
|
||||
<p className="font-semibold">
|
||||
@@ -70,17 +45,17 @@ export default function Routes({
|
||||
</p>
|
||||
</TableList.Item>
|
||||
) : undefined}
|
||||
{subnet.map((route) => (
|
||||
<TableList.Item key={route.id}>
|
||||
<p>{route.prefix}</p>
|
||||
{subnets.map((route) => (
|
||||
<TableList.Item key={route}>
|
||||
<p>{route}</p>
|
||||
<Switch
|
||||
defaultSelected={route.enabled}
|
||||
defaultSelected={node.approvedRoutes.includes(route)}
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData();
|
||||
form.set('id', machine.id);
|
||||
form.set('_method', 'routes');
|
||||
form.set('route', route.id);
|
||||
form.set('action_id', 'update_routes');
|
||||
form.set('node_id', node.id);
|
||||
form.set('routes', [route].join(','));
|
||||
|
||||
form.set('enabled', String(checked));
|
||||
fetcher.submit(form, {
|
||||
@@ -102,7 +77,7 @@ export default function Routes({
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
<TableList className="mt-4">
|
||||
{exit.length === 0 ? (
|
||||
{node.customRouting.exitRoutes.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>
|
||||
@@ -111,13 +86,18 @@ export default function Routes({
|
||||
<TableList.Item>
|
||||
<p>Use as exit node</p>
|
||||
<Switch
|
||||
defaultSelected={exitEnabled}
|
||||
defaultSelected={node.customRouting.exitApproved}
|
||||
label="Enabled"
|
||||
onChange={(checked) => {
|
||||
const form = new FormData();
|
||||
form.set('id', machine.id);
|
||||
form.set('_method', 'exit-node');
|
||||
form.set('routes', exit.map((route) => route.id).join(','));
|
||||
form.set('action_id', 'update_routes');
|
||||
form.set('node_id', node.id);
|
||||
form.set(
|
||||
'routes',
|
||||
node.customRouting.exitRoutes
|
||||
.map((route) => route)
|
||||
.join(','),
|
||||
);
|
||||
|
||||
form.set('enabled', String(checked));
|
||||
fetcher.submit(form, {
|
||||
|
||||
@@ -33,8 +33,8 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
||||
</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="action_id" value="update_tags" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||
<TableList className="mt-4">
|
||||
{tags.length === 0 ? (
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { type ActionFunctionArgs, data, redirect } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { Machine } from '~/types';
|
||||
|
||||
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,
|
||||
);
|
||||
|
||||
const formData = await request.formData();
|
||||
const apiKey = session.get('api_key')!;
|
||||
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('Missing `action_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
});
|
||||
}
|
||||
|
||||
return registerMachine(formData, apiKey, context);
|
||||
}
|
||||
|
||||
// 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 { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const node = nodes.find((node) => node.id === nodeId);
|
||||
if (!node) {
|
||||
throw data(`Machine with ID ${nodeId} not found`, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
node.user.providerId?.split('/').pop() !== session.get('user')!.subject &&
|
||||
!check
|
||||
) {
|
||||
throw data('You do not have permission to act on this machine', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'rename': {
|
||||
return renameMachine(formData, apiKey, nodeId, context);
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
return deleteMachine(apiKey, nodeId, context);
|
||||
}
|
||||
|
||||
case 'expire': {
|
||||
return expireMachine(apiKey, nodeId, context);
|
||||
}
|
||||
|
||||
case 'update_tags': {
|
||||
return updateTags(formData, apiKey, nodeId, context);
|
||||
}
|
||||
|
||||
case 'update_routes': {
|
||||
return updateRoutes(formData, apiKey, nodeId, context);
|
||||
}
|
||||
|
||||
case 'reassign': {
|
||||
return reassignMachine(formData, apiKey, nodeId, context);
|
||||
}
|
||||
|
||||
default:
|
||||
throw data('Invalid action', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function registerMachine(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const registrationKey = formData.get('register_key')?.toString();
|
||||
if (!registrationKey) {
|
||||
throw data('Missing `register_key` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = formData.get('user')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const qp = new URLSearchParams();
|
||||
qp.append('user', user);
|
||||
qp.append('key', registrationKey);
|
||||
const url = `v1/node/register?${qp.toString()}`;
|
||||
const { node } = await context.client.post<{ node: Machine }>(url, apiKey, {
|
||||
user,
|
||||
key: registrationKey,
|
||||
});
|
||||
|
||||
return redirect(`/machines/${node.id}`);
|
||||
}
|
||||
|
||||
async function renameMachine(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const newName = formData.get('name')?.toString();
|
||||
if (!newName) {
|
||||
throw data('Missing `name` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const name = String(formData.get('name'));
|
||||
await context.client.post(`v1/node/${nodeId}/rename/${name}`, apiKey);
|
||||
return { message: 'Machine renamed' };
|
||||
}
|
||||
|
||||
async function deleteMachine(
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
await context.client.delete(`v1/node/${nodeId}`, apiKey);
|
||||
return redirect('/machines');
|
||||
}
|
||||
|
||||
async function expireMachine(
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
await context.client.post(`v1/node/${nodeId}/expire`, apiKey);
|
||||
return { message: 'Machine expired' };
|
||||
}
|
||||
|
||||
async function updateTags(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const tags = formData.get('tags')?.toString().split(',') ?? [];
|
||||
if (tags.length === 0) {
|
||||
throw data('Missing `tags` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.client.post(`v1/node/${nodeId}/tags`, apiKey, {
|
||||
tags: tags.map((tag) => tag.trim()).filter((tag) => tag !== ''),
|
||||
});
|
||||
|
||||
return { message: 'Tags updated' };
|
||||
}
|
||||
|
||||
async function updateRoutes(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const { node } = await context.client.get<{ node: Machine }>(
|
||||
`v1/node/${nodeId}`,
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const newApproved = node.approvedRoutes;
|
||||
const routes = formData.get('routes')?.toString();
|
||||
if (!routes) {
|
||||
throw data('Missing `routes` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const allRoutes = routes.split(',').map((route) => route.trim());
|
||||
if (allRoutes.length === 0) {
|
||||
throw data('No routes provided to update', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const enabled = formData.get('enabled')?.toString();
|
||||
if (enabled === undefined) {
|
||||
throw data('Missing `enabled` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (enabled === 'true') {
|
||||
for (const route of allRoutes) {
|
||||
// If already approved, skip, otherwise add to approved
|
||||
if (newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newApproved.push(route);
|
||||
}
|
||||
} else {
|
||||
for (const route of allRoutes) {
|
||||
// If not approved, skip, otherwise remove from approved
|
||||
if (!newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const index = newApproved.indexOf(route);
|
||||
if (index > -1) {
|
||||
newApproved.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await context.client.post(`v1/node/${nodeId}/approve_routes`, apiKey, {
|
||||
routes: newApproved,
|
||||
});
|
||||
|
||||
return { message: 'Routes updated' };
|
||||
}
|
||||
|
||||
async function reassignMachine(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.client.post(`v1/node/${nodeId}/user`, apiKey, {
|
||||
user,
|
||||
});
|
||||
|
||||
return { message: 'Machine reassigned' };
|
||||
}
|
||||
+203
-120
@@ -2,6 +2,7 @@ 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 { mapTag } from 'yaml/util';
|
||||
import Attribute from '~/components/Attribute';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
@@ -9,100 +10,70 @@ import Chip from '~/components/Chip';
|
||||
import Link from '~/components/Link';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import { pull } from '~/utils/headscale';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import { menuAction } from './action';
|
||||
import { getOSInfo, getTSVersion } from '~/utils/host-info';
|
||||
import { mapNodes } from '~/utils/node-info';
|
||||
import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
|
||||
import MenuOptions from './components/menu';
|
||||
import Routes from './dialogs/routes';
|
||||
import { machineAction } from './machine-actions';
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
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');
|
||||
}
|
||||
|
||||
const { mode, config } = hs_getConfig();
|
||||
let magic: string | undefined;
|
||||
|
||||
if (mode !== 'no') {
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
|
||||
const [machine, routes, users] = await Promise.all([
|
||||
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
|
||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
const [machine, { users }] = await Promise.all([
|
||||
context.client.get<{ node: Machine }>(
|
||||
`v1/node/${params.id}`,
|
||||
session.get('api_key')!,
|
||||
),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
||||
]);
|
||||
|
||||
const lookup = await context.agents?.lookup([machine.node.nodeKey]);
|
||||
const [node] = mapNodes([machine.node], lookup);
|
||||
const tags = Array.from(
|
||||
new Set([...node.validTags, ...node.forcedTags]),
|
||||
).sort();
|
||||
|
||||
return {
|
||||
machine: machine.node,
|
||||
routes: routes.routes.filter((route) => route.node.id === params.id),
|
||||
users: users.users,
|
||||
node,
|
||||
tags,
|
||||
users,
|
||||
magic,
|
||||
agent: context.agents?.agentID(),
|
||||
stats: lookup?.[node.nodeKey],
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
return menuAction(request);
|
||||
export async function action(request: ActionFunctionArgs) {
|
||||
return machineAction(request);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { machine, magic, routes, users } = useLoaderData<typeof loader>();
|
||||
const { node, tags, magic, users, agent, stats } =
|
||||
useLoaderData<typeof loader>();
|
||||
const [showRouting, setShowRouting] = useState(false);
|
||||
|
||||
const expired =
|
||||
machine.expiry === '0001-01-01 00:00:00' ||
|
||||
machine.expiry === '0001-01-01T00:00:00Z' ||
|
||||
machine.expiry === null
|
||||
? false
|
||||
: new Date(machine.expiry).getTime() < Date.now();
|
||||
|
||||
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
|
||||
|
||||
if (expired) {
|
||||
tags.unshift('Expired');
|
||||
}
|
||||
|
||||
// This is much easier with Object.groupBy but it's too new for us
|
||||
const { exit, subnet, subnetApproved } = routes.reduce<{
|
||||
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');
|
||||
}
|
||||
const uiTags = useMemo(() => {
|
||||
const tags = uiTagsForNode(node, agent === node.nodeKey);
|
||||
return tags;
|
||||
}, [node, agent]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -111,7 +82,7 @@ export default function Page() {
|
||||
All Machines
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span>
|
||||
{machine.givenName}
|
||||
{node.givenName}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
@@ -120,17 +91,10 @@ export default function Page() {
|
||||
)}
|
||||
>
|
||||
<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" />
|
||||
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
||||
<StatusCircle isOnline={node.online} className="w-4 h-4" />
|
||||
</span>
|
||||
|
||||
<MenuOptions
|
||||
isFullButton
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
users={users}
|
||||
magic={magic}
|
||||
/>
|
||||
<MenuOptions isFullButton node={node} 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">
|
||||
@@ -145,7 +109,7 @@ export default function Page() {
|
||||
</span>
|
||||
<div className="flex items-center gap-x-2.5 mt-1">
|
||||
<UserCircle />
|
||||
{machine.user.name}
|
||||
{node.user.name}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 pl-4">
|
||||
@@ -153,19 +117,15 @@ export default function Page() {
|
||||
Status
|
||||
</p>
|
||||
<div className="flex gap-1 mt-1 mb-8">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{tags.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
|
||||
<Routes
|
||||
machine={machine}
|
||||
routes={routes}
|
||||
isOpen={showRouting}
|
||||
setIsOpen={setShowRouting}
|
||||
/>
|
||||
<Routes node={node} isOpen={showRouting} setIsOpen={setShowRouting} />
|
||||
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p>
|
||||
Subnets let you expose physical network routes onto Tailscale.{' '}
|
||||
@@ -196,12 +156,12 @@ export default function Page() {
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{subnetApproved.length === 0 ? (
|
||||
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{subnetApproved.map((route) => (
|
||||
<li key={route.id}>{route.prefix}</li>
|
||||
{node.customRouting.subnetApprovedRoutes.map((route) => (
|
||||
<li key={route}>{route}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
@@ -228,12 +188,12 @@ export default function Page() {
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{subnet.length === 0 ? (
|
||||
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{subnet.map((route) => (
|
||||
<li key={route.id}>{route.prefix}</li>
|
||||
{node.customRouting.subnetWaitingRoutes.map((route) => (
|
||||
<li key={route}>{route}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
@@ -259,9 +219,9 @@ export default function Page() {
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{exit.length === 0 ? (
|
||||
{node.customRouting.exitRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : exitEnabled ? (
|
||||
) : node.customRouting.exitApproved ? (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-700" />
|
||||
Allowed
|
||||
@@ -284,33 +244,156 @@ export default function Page() {
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<h2 className="text-xl font-medium mb-4">Machine Details</h2>
|
||||
<Card variant="flat" className="w-full max-w-full">
|
||||
<Attribute name="Creator" value={machine.user.name} />
|
||||
<Attribute name="Node ID" value={machine.id} />
|
||||
<Attribute name="Node Name" value={machine.givenName} />
|
||||
<Attribute name="Hostname" value={machine.name} />
|
||||
<Attribute isCopyable name="Node Key" value={machine.nodeKey} />
|
||||
<Attribute
|
||||
name="Created"
|
||||
value={new Date(machine.createdAt).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Last Seen"
|
||||
value={new Date(machine.lastSeen).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Expiry"
|
||||
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
|
||||
/>
|
||||
{magic ? (
|
||||
<h2 className="text-xl font-medium">Machine Details</h2>
|
||||
<p className="mb-4">
|
||||
Information about this machine’s network. Used to debug connection
|
||||
issues.
|
||||
</p>
|
||||
<Card
|
||||
variant="flat"
|
||||
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Attribute name="Creator" value={node.user.name} />
|
||||
<Attribute name="Machine name" value={node.givenName} />
|
||||
<Attribute
|
||||
tooltip="OS hostname is published by the machine’s operating system and is used as the default name for the machine."
|
||||
name="OS hostname"
|
||||
value={node.name}
|
||||
/>
|
||||
{stats ? (
|
||||
<>
|
||||
<Attribute name="OS" value={getOSInfo(stats)} />
|
||||
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
|
||||
</>
|
||||
) : undefined}
|
||||
<Attribute
|
||||
tooltip="ID for this machine. Used in the Headscale API."
|
||||
name="ID"
|
||||
value={node.id}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Domain"
|
||||
value={`${machine.givenName}.${magic}`}
|
||||
tooltip="Public key which uniquely identifies this machine."
|
||||
name="Node key"
|
||||
value={node.nodeKey}
|
||||
/>
|
||||
) : undefined}
|
||||
<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="uppercase text-sm font-semibold opacity-75">
|
||||
Addresses
|
||||
</p>
|
||||
<Attribute
|
||||
isCopyable
|
||||
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
||||
name="Tailscale IPv4"
|
||||
value={getIpv4Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
tooltip="This machine’s IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
|
||||
name="Tailscale IPv6"
|
||||
value={getIpv6Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
tooltip="Users of your tailnet can use this DNS short name to access this machine."
|
||||
name="Short domain"
|
||||
value={node.givenName}
|
||||
/>
|
||||
{magic ? (
|
||||
<Attribute
|
||||
isCopyable
|
||||
tooltip="Users of your tailnet can use this DNS name to access this machine."
|
||||
name="Full domain"
|
||||
value={`${node.givenName}.${magic}`}
|
||||
/>
|
||||
) : undefined}
|
||||
{stats ? (
|
||||
<>
|
||||
<p className="uppercase text-sm font-semibold opacity-75 mt-4">
|
||||
Client Connectivity
|
||||
</p>
|
||||
<Attribute
|
||||
tooltip="Whether the machine is behind a difficult NAT that varies the machine’s IP address depending on the destination."
|
||||
name="Varies"
|
||||
value={stats.NetInfo?.MappingVariesByDestIP ? 'Yes' : 'No'}
|
||||
/>
|
||||
<Attribute
|
||||
tooltip="Whether the machine needs to traverse NATs with hairpinning."
|
||||
name="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>
|
||||
);
|
||||
}
|
||||
|
||||
function getIpv4Address(addresses: string[]) {
|
||||
for (const address of addresses) {
|
||||
if (address.startsWith('100.')) {
|
||||
// Return the first CGNAT address
|
||||
return address;
|
||||
}
|
||||
}
|
||||
|
||||
return '—';
|
||||
}
|
||||
|
||||
function getIpv6Address(addresses: string[]) {
|
||||
for (const address of addresses) {
|
||||
if (address.startsWith('fd')) {
|
||||
// Return the first IPv6 address
|
||||
return address;
|
||||
}
|
||||
}
|
||||
|
||||
return '—';
|
||||
}
|
||||
|
||||
@@ -1,66 +1,82 @@
|
||||
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 type { Machine, Route, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { pull } from '~/utils/headscale';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import { initAgentSocket, queryAgent } from '~/utils/ws-agent';
|
||||
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import { noContext } from '~/utils/log';
|
||||
import { AppContext } from '~server/context/app';
|
||||
import { menuAction } from './action';
|
||||
import MachineRow from './components/machine';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { mapNodes } from '~/utils/node-info';
|
||||
import MachineRow from './components/machine-row';
|
||||
import NewMachine from './dialogs/new';
|
||||
import { machineAction } from './machine-actions';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
const [machines, routes, users] = await Promise.all([
|
||||
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
]);
|
||||
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
}: 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.');
|
||||
}
|
||||
|
||||
initAgentSocket(context);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.read_machines,
|
||||
);
|
||||
|
||||
const stats = await queryAgent(machines.nodes.map((node) => node.nodeKey));
|
||||
const ctx = context.context;
|
||||
const { mode, config } = hs_getConfig();
|
||||
if (!check) {
|
||||
// Not authorized to view this page
|
||||
throw new Error(
|
||||
'You do not have permission to view this page. Please contact your administrator.',
|
||||
);
|
||||
}
|
||||
|
||||
const writablePermission = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_machines,
|
||||
);
|
||||
|
||||
const [{ nodes }, { users }] = await Promise.all([
|
||||
context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.get('api_key')!,
|
||||
),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
||||
]);
|
||||
|
||||
let magic: string | undefined;
|
||||
|
||||
if (mode !== 'no') {
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain;
|
||||
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);
|
||||
|
||||
return {
|
||||
nodes: machines.nodes,
|
||||
routes: routes.routes,
|
||||
users: users.users,
|
||||
populatedNodes,
|
||||
nodes,
|
||||
users,
|
||||
magic,
|
||||
stats,
|
||||
server: ctx.headscale.url,
|
||||
publicServer: ctx.headscale.public_url,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
return menuAction(request);
|
||||
export async function action(request: ActionFunctionArgs) {
|
||||
return machineAction(request);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
@@ -84,6 +100,8 @@ export default function Page() {
|
||||
<NewMachine
|
||||
server={data.publicServer ?? data.server}
|
||||
users={data.users}
|
||||
isDisabled={!data.writable}
|
||||
disabledKeys={data.preAuth ? [] : ['pre-auth']}
|
||||
/>
|
||||
</div>
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
@@ -108,7 +126,10 @@ export default function Page() {
|
||||
) : undefined}
|
||||
</div>
|
||||
</th>
|
||||
{/**<th className="uppercase text-xs font-bold pb-2">Version</th>**/}
|
||||
{/* We only want to show the version column if there are agents */}
|
||||
{data.agent !== 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>
|
||||
@@ -118,16 +139,18 @@ export default function Page() {
|
||||
'border-t border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{data.nodes.map((machine) => (
|
||||
{data.populatedNodes.map((machine) => (
|
||||
<MachineRow
|
||||
key={machine.id}
|
||||
machine={machine}
|
||||
routes={data.routes.filter(
|
||||
(route) => route.node.id === machine.id,
|
||||
)}
|
||||
node={machine}
|
||||
users={data.users}
|
||||
magic={data.magic}
|
||||
stats={data.stats?.[machine.nodeKey]}
|
||||
isAgent={data.agent ? data.agent === machine.nodeKey : undefined}
|
||||
isDisabled={
|
||||
data.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
: machine.user.providerId?.split('/').pop() !== data.subject
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -135,7 +158,3 @@ export default function Page() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ErrorPopup type="embedded" />;
|
||||
}
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
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 { PreAuthKey, User } from '~/types';
|
||||
import { post, pull } from '~/utils/headscale';
|
||||
import { noContext } from '~/utils/log';
|
||||
import { send } from '~/utils/res';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
import AuthKeyRow from './components/key';
|
||||
import AddPreAuthKey from './dialogs/new';
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (!session.has('hsApiKey')) {
|
||||
return send(
|
||||
{ message: 'Unauthorized' },
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const data = await request.formData();
|
||||
|
||||
// Expiring a pre-auth key
|
||||
if (request.method === 'DELETE') {
|
||||
const key = data.get('key');
|
||||
const user = data.get('user');
|
||||
|
||||
if (!key || !user) {
|
||||
return send(
|
||||
{ message: 'Missing parameters' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey/expire',
|
||||
session.get('hsApiKey')!,
|
||||
{
|
||||
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 post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey',
|
||||
session.get('hsApiKey')!,
|
||||
{
|
||||
user: user,
|
||||
ephemeral: ephemeral === 'on',
|
||||
reusable: reusable === 'on',
|
||||
expiration: date.toISOString(),
|
||||
aclTags: [], // TODO
|
||||
},
|
||||
);
|
||||
|
||||
return { message: 'Pre-auth key created', key };
|
||||
}
|
||||
}
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
const users = await pull<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.get('hsApiKey')!,
|
||||
);
|
||||
|
||||
if (!context) {
|
||||
throw noContext();
|
||||
}
|
||||
|
||||
const ctx = context.context;
|
||||
const preAuthKeys = await Promise.all(
|
||||
users.users.map((user) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.set('user', user.name);
|
||||
|
||||
return pull<{ preAuthKeys: PreAuthKey[] }>(
|
||||
`v1/preauthkey?${qp.toString()}`,
|
||||
session.get('hsApiKey')!,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
keys: preAuthKeys.flatMap((keys) => keys.preAuthKeys),
|
||||
users: users.users,
|
||||
server: ctx.headscale.public_url ?? ctx.headscale.url,
|
||||
};
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { PreAuthKey } from '~/types';
|
||||
|
||||
export async function authKeysAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.generate_authkeys,
|
||||
);
|
||||
|
||||
if (!check) {
|
||||
throw data('You do not have permission to manage pre-auth keys', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const apiKey = session.get('api_key')!;
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('Missing `action_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add_preauthkey':
|
||||
return await addPreAuthKey(formData, apiKey, context);
|
||||
case 'expire_preauthkey':
|
||||
return await expirePreAuthKey(formData, apiKey, context);
|
||||
default:
|
||||
return data('Invalid action', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function addPreAuthKey(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
return data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const expiry = formData.get('expiry')?.toString();
|
||||
if (!expiry) {
|
||||
return data('Missing `expiry` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const reusable = formData.get('reusable')?.toString();
|
||||
if (!reusable) {
|
||||
return data('Missing `reusable` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const ephemeral = formData.get('ephemeral')?.toString();
|
||||
if (!ephemeral) {
|
||||
return data('Missing `ephemeral` in the form data.', {
|
||||
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);
|
||||
|
||||
await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey',
|
||||
apiKey,
|
||||
{
|
||||
user,
|
||||
ephemeral: ephemeral === 'on',
|
||||
reusable: reusable === 'on',
|
||||
expiration: date.toISOString(),
|
||||
aclTags: [], // TODO
|
||||
},
|
||||
);
|
||||
|
||||
return data('Pre-auth key created');
|
||||
}
|
||||
|
||||
async function expirePreAuthKey(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const key = formData.get('key')?.toString();
|
||||
if (!key) {
|
||||
return data('Missing `key` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
return data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.client.post('v1/preauthkey/expire', apiKey, { user, key });
|
||||
return data('Pre-auth key expired');
|
||||
}
|
||||
+10
-10
@@ -1,24 +1,24 @@
|
||||
import type { PreAuthKey } from '~/types';
|
||||
|
||||
import Attribute from '~/components/Attribute';
|
||||
import Button from '~/components/Button';
|
||||
import Code from '~/components/Code';
|
||||
import type { PreAuthKey, User } from '~/types';
|
||||
import toast from '~/utils/toast';
|
||||
import ExpireKey from '../dialogs/expire';
|
||||
import ExpireAuthKey from './dialogs/expire-auth-key';
|
||||
|
||||
interface Props {
|
||||
authKey: PreAuthKey;
|
||||
server: string;
|
||||
user: User;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export default function AuthKeyRow({ authKey, server }: Props) {
|
||||
export default function AuthKeyRow({ authKey, user, url }: Props) {
|
||||
const createdAt = new Date(authKey.createdAt).toLocaleString();
|
||||
const expiration = new Date(authKey.expiration).toLocaleString();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Attribute name="Key" value={authKey.key} isCopyable />
|
||||
<Attribute name="User" value={authKey.user} isCopyable />
|
||||
<Attribute name="User" value={user.name} isCopyable />
|
||||
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
|
||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
|
||||
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
|
||||
@@ -28,19 +28,19 @@ export default function AuthKeyRow({ authKey, server }: Props) {
|
||||
To use this key, run the following command on your device:
|
||||
</p>
|
||||
<Code className="text-sm">
|
||||
tailscale up --login-server {server} --authkey {authKey.key}
|
||||
tailscale up --login-server={url} --authkey {authKey.key}
|
||||
</Code>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div suppressHydrationWarning className="flex gap-4 items-center">
|
||||
{(authKey.used && !authKey.reusable) ||
|
||||
new Date(authKey.expiration) < new Date() ? undefined : (
|
||||
<ExpireKey authKey={authKey} />
|
||||
<ExpireAuthKey authKey={authKey} user={user} />
|
||||
)}
|
||||
<Button
|
||||
variant="light"
|
||||
className="my-4"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`tailscale up --login-server ${server} --authkey ${authKey.key}`,
|
||||
`tailscale up --login-server=${url} --authkey ${authKey.key}`,
|
||||
);
|
||||
|
||||
toast('Copied command to clipboard');
|
||||
+15
-9
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Key, useState } from 'react';
|
||||
import { useFetcher } from 'react-router';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Link from '~/components/Link';
|
||||
@@ -7,30 +7,36 @@ import Select from '~/components/Select';
|
||||
import Switch from '~/components/Switch';
|
||||
import type { User } from '~/types';
|
||||
|
||||
interface Props {
|
||||
interface AddAuthKeyProps {
|
||||
users: User[];
|
||||
}
|
||||
|
||||
// TODO: Tags
|
||||
export default function AddPreAuthKey(data: Props) {
|
||||
export default function AddAuthKey(data: AddAuthKeyProps) {
|
||||
const [reusable, setReusable] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const [userId, setUserId] = useState<Key | null>(data.users[0]?.id);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Generate auth key</Dialog.Title>
|
||||
<Dialog.Text className="font-semibold">User</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">Attach this key to a user</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="add_preauthkey" />
|
||||
<input type="hidden" name="user_id" value={userId?.toString()} />
|
||||
<Select
|
||||
isRequired
|
||||
label="Owner"
|
||||
label="User"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
description="This is the user machines will belong to when they authenticate."
|
||||
className="mb-2"
|
||||
onSelectionChange={(value) => {
|
||||
setUserId(value);
|
||||
}}
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.name}>{user.name}</Select.Item>
|
||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<NumberInput
|
||||
@@ -47,7 +53,7 @@ export default function AddPreAuthKey(data: Props) {
|
||||
unitDisplay: 'short',
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div className="flex justify-between items-center gap-2 mt-6">
|
||||
<div>
|
||||
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
@@ -64,7 +70,7 @@ export default function AddPreAuthKey(data: Props) {
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="reusable" value={reusable.toString()} />
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<div className="flex justify-between items-center gap-2 mt-6">
|
||||
<div>
|
||||
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
|
||||
<Dialog.Text className="text-sm">
|
||||
+8
-6
@@ -1,17 +1,19 @@
|
||||
import Dialog from '~/components/Dialog';
|
||||
import type { PreAuthKey } from '~/types';
|
||||
import type { PreAuthKey, User } from '~/types';
|
||||
|
||||
interface Props {
|
||||
interface ExpireAuthKeyProps {
|
||||
authKey: PreAuthKey;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export default function ExpireKey({ authKey }: Props) {
|
||||
export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>Expire Key</Dialog.Button>
|
||||
<Dialog.Panel method="DELETE" variant="destructive">
|
||||
<Dialog.Button variant="heavy">Expire Key</Dialog.Button>
|
||||
<Dialog.Panel variant="destructive">
|
||||
<Dialog.Title>Expire auth key?</Dialog.Title>
|
||||
<input type="hidden" name="user" value={authKey.user} />
|
||||
<input type="hidden" name="action_id" value="expire_preauthkey" />
|
||||
<input type="hidden" name="user_id" value={user.id} />
|
||||
<input type="hidden" name="key" value={authKey.key} />
|
||||
<Dialog.Text>
|
||||
Expiring this authentication key will immediately prevent it from
|
||||
@@ -0,0 +1,255 @@
|
||||
import { FileKey2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import Select from '~/components/Select';
|
||||
import TableList from '~/components/TableList';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { PreAuthKey, User } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import { authKeysAction } from './actions';
|
||||
import AuthKeyRow from './auth-key-row';
|
||||
import AddAuthKey from './dialogs/add-auth-key';
|
||||
|
||||
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
|
||||
.filter((user) => user.name?.length > 0) // Filter out any invalid users
|
||||
.map(async (user) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.set('user', user.id);
|
||||
|
||||
try {
|
||||
const { preAuthKeys } = await context.client.get<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>(`v1/preauthkey?${qp.toString()}`, session.get('api_key')!);
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
preAuthKeys,
|
||||
};
|
||||
} catch (error) {
|
||||
log.error('api', 'GET /v1/preauthkey for %s: %o', user.name, error);
|
||||
return {
|
||||
success: false,
|
||||
user,
|
||||
error,
|
||||
preAuthKeys: [] as PreAuthKey[],
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const keys = preAuthKeys
|
||||
.filter(({ success }) => success)
|
||||
.map(({ user, preAuthKeys }) => ({
|
||||
user,
|
||||
preAuthKeys,
|
||||
}));
|
||||
|
||||
const missing = preAuthKeys
|
||||
.filter(({ success }) => !success)
|
||||
.map(({ user, error }) => ({
|
||||
user,
|
||||
error,
|
||||
}));
|
||||
|
||||
return {
|
||||
keys,
|
||||
missing,
|
||||
users,
|
||||
access: await context.sessions.check(
|
||||
request,
|
||||
Capabilities.generate_authkeys,
|
||||
),
|
||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(request: ActionFunctionArgs<LoadContext>) {
|
||||
return authKeysAction(request);
|
||||
}
|
||||
|
||||
type Status = 'all' | 'active' | 'expired' | 'reusable' | 'ephemeral';
|
||||
export default function Page() {
|
||||
const { keys, missing, users, url, access } = useLoaderData<typeof loader>();
|
||||
const [selectedUser, setSelectedUser] = useState('__headplane_all');
|
||||
const [status, setStatus] = useState<Status>('active');
|
||||
const isDisabled =
|
||||
!access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0;
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
const now = new Date();
|
||||
return keys
|
||||
.filter(({ user }) => {
|
||||
if (selectedUser === '__headplane_all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return user.id === selectedUser;
|
||||
})
|
||||
.flatMap(({ preAuthKeys }) => preAuthKeys)
|
||||
.filter((key) => {
|
||||
if (status === 'all') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (status === 'ephemeral') {
|
||||
return key.ephemeral;
|
||||
}
|
||||
|
||||
if (status === 'reusable') {
|
||||
return key.reusable;
|
||||
}
|
||||
|
||||
const expiry = new Date(key.expiration);
|
||||
if (status === 'expired') {
|
||||
// Expired keys are either used or expired
|
||||
// BUT only used if they are not reusable
|
||||
if (key.used && !key.reusable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return expiry < now;
|
||||
}
|
||||
|
||||
if (status === 'active') {
|
||||
// Active keys are either not expired or reusable
|
||||
if (expiry < now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!key.used) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return key.reusable;
|
||||
}
|
||||
});
|
||||
}, [keys, selectedUser, status]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md: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>
|
||||
{!access ? (
|
||||
<Notice title="Pre-auth key permissions restricted" variant="warning">
|
||||
You do not have the necessary permissions to generate pre-auth keys.
|
||||
Please contact your administrator to request access or to generate a
|
||||
pre-auth key for you.
|
||||
</Notice>
|
||||
) : missing.length > 0 ? (
|
||||
<Notice title="Missing authentication keys" variant="error">
|
||||
An error occurred while fetching the authentication keys for the
|
||||
following users:{' '}
|
||||
{missing.map(({ user }, index) => (
|
||||
<>
|
||||
<Code key={user.name}>{user.name}</Code>
|
||||
{index < missing.length - 1 ? ', ' : '. '}
|
||||
</>
|
||||
))}
|
||||
Their keys may not be listed correctly. Please check the server logs
|
||||
for more information.
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<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>
|
||||
<AddAuthKey users={users} />
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<Select
|
||||
label="User"
|
||||
placeholder="Select a user"
|
||||
className="w-full"
|
||||
defaultSelectedKey="__headplane_all"
|
||||
isDisabled={isDisabled}
|
||||
onSelectionChange={(value) =>
|
||||
setSelectedUser(value?.toString() ?? '')
|
||||
}
|
||||
>
|
||||
{[
|
||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||
...keys.map(({ user }) => (
|
||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
||||
)),
|
||||
]}
|
||||
</Select>
|
||||
<Select
|
||||
label="Status"
|
||||
placeholder="Select a status"
|
||||
className="w-full"
|
||||
defaultSelectedKey="active"
|
||||
isDisabled={isDisabled}
|
||||
onSelectionChange={(value) =>
|
||||
setStatus((value?.toString() ?? 'active') as Status)
|
||||
}
|
||||
>
|
||||
<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">
|
||||
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<FileKey2 />
|
||||
<p className="font-semibold">
|
||||
No pre-auth keys have been created yet.
|
||||
</p>
|
||||
</TableList.Item>
|
||||
) : filteredKeys.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<FileKey2 />
|
||||
<p className="font-semibold">
|
||||
No pre-auth keys match the selected filters.
|
||||
</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
filteredKeys.map((key) => {
|
||||
// TODO: Why is Headscale using email as the user ID here?
|
||||
// https://github.com/juanfont/headscale/issues/2520
|
||||
const user = users.find((user) => user.id === key.user.id);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableList.Item key={key.id}>
|
||||
<AuthKeyRow authKey={key} url={url} user={user} />
|
||||
</TableList.Item>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,22 @@
|
||||
import { ArrowRightIcon } from '@primer/octicons-react';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
Link as RemixLink,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import Link from '~/components/Link';
|
||||
import cn from '~/utils/cn';
|
||||
import { LoadContext } from '~/server';
|
||||
|
||||
import AgentSection from './components/agent';
|
||||
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
|
||||
return {
|
||||
config: context.hs.writable(),
|
||||
oidc: context.oidc,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { config, oidc } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-screen-lg">
|
||||
<div className="flex flex-col w-2/3">
|
||||
@@ -37,7 +47,34 @@ export default function Page() {
|
||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||
</div>
|
||||
</RemixLink>
|
||||
{/**<AgentSection />**/}
|
||||
{config && oidc ? (
|
||||
<>
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">
|
||||
Authentication Restrictions
|
||||
</h1>
|
||||
<p>
|
||||
Headscale supports restricting OIDC authentication to only allow
|
||||
certain email domains, groups, or users to authenticate. This can
|
||||
be used to limit access to your Tailnet to only certain users or
|
||||
groups and Headplane will also respect these settings when
|
||||
authenticating.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||
name="Headscale OIDC documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RemixLink to="/settings/restrictions">
|
||||
<div className="text-lg font-medium flex items-center">
|
||||
Manage Restrictions
|
||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
||||
</div>
|
||||
</RemixLink>
|
||||
</>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
|
||||
export async function restrictionAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.configure_iam,
|
||||
);
|
||||
|
||||
if (!check) {
|
||||
throw data('You do not have permission to modify IAM settings.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.hs.writable()) {
|
||||
throw data('The Headscale configuration file is not editable.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('No action provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add_domain': {
|
||||
return addDomain(formData, context);
|
||||
}
|
||||
|
||||
case 'remove_domain': {
|
||||
return removeDomain(formData, context);
|
||||
}
|
||||
|
||||
case 'add_group': {
|
||||
return addGroup(formData, context);
|
||||
}
|
||||
|
||||
case 'remove_group': {
|
||||
return removeGroup(formData, context);
|
||||
}
|
||||
|
||||
case 'add_user': {
|
||||
return addUser(formData, context);
|
||||
}
|
||||
|
||||
case 'remove_user': {
|
||||
return removeUser(formData, context);
|
||||
}
|
||||
|
||||
default: {
|
||||
throw data('Invalid action provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addDomain(formData: FormData, context: LoadContext) {
|
||||
const domain = formData.get('domain')?.toString()?.trim();
|
||||
if (!domain) {
|
||||
throw data('No domain provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const domains = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
|
||||
];
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('Domain added successfully.');
|
||||
}
|
||||
|
||||
async function removeDomain(formData: FormData, context: LoadContext) {
|
||||
const domain = formData.get('domain')?.toString()?.trim();
|
||||
if (!domain) {
|
||||
throw data('No domain provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
|
||||
if (!storedDomains.includes(domain)) {
|
||||
// Domain not found in the list
|
||||
throw data(`Domain "${domain}" not found in allowed domains.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out the domain to remove it from the list
|
||||
const domains = storedDomains.filter((d: string) => d !== domain);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('Domain removed successfully.');
|
||||
}
|
||||
|
||||
async function addUser(formData: FormData, context: LoadContext) {
|
||||
const user = formData.get('user')?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data('No user provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const users = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
|
||||
];
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_users',
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('User added successfully.');
|
||||
}
|
||||
|
||||
async function removeUser(formData: FormData, context: LoadContext) {
|
||||
const user = formData.get('user')?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data('No user provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
|
||||
if (!storedUsers.includes(user)) {
|
||||
// User not found in the list
|
||||
throw data(`User "${user}" not found in allowed users.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out the user to remove it from the list
|
||||
const users = storedUsers.filter((d: string) => d !== user);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_users',
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('User removed successfully.');
|
||||
}
|
||||
|
||||
async function addGroup(formData: FormData, context: LoadContext) {
|
||||
const group = formData.get('group')?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data('No group provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const groups = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
|
||||
];
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_groups',
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('Group added successfully.');
|
||||
}
|
||||
|
||||
async function removeGroup(formData: FormData, context: LoadContext) {
|
||||
const group = formData.get('group')?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data('No group provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
|
||||
if (!storedGroups.includes(group)) {
|
||||
// Group not found in the list
|
||||
throw data(`Group "${group}" not found in allowed groups.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out the group to remove it from the list
|
||||
const groups = storedGroups.filter((d: string) => d !== group);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_groups',
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('Group removed successfully.');
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
|
||||
interface AddDomainProps {
|
||||
domains: string[];
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
|
||||
const [domain, setDomain] = useState('');
|
||||
|
||||
const isInvalid = useMemo(() => {
|
||||
if (!domain || domain.trim().length === 0) {
|
||||
// Empty domain is invalid, but no error shown
|
||||
return false;
|
||||
}
|
||||
|
||||
if (domains.includes(domain.trim())) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if domain is a valid FQDN
|
||||
const url = new URL(`http://${domain.trim()}`);
|
||||
return url.hostname !== domain.trim();
|
||||
} catch (e) {
|
||||
// If URL constructor fails, it's not a valid domain
|
||||
return true;
|
||||
}
|
||||
}, [domain, domains]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={isDisabled}>Add domain</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Add domain</Dialog.Title>
|
||||
<Dialog.Text className="mb-4">
|
||||
Add this domain to a list of allowed email domains that can
|
||||
authenticate with Headscale via OIDC.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="add_domain" />
|
||||
<Input
|
||||
isRequired
|
||||
label="Domain"
|
||||
description={
|
||||
domain.trim().length > 0
|
||||
? `Matches users with <user>@${domain.trim()}`
|
||||
: 'Enter a domain to match users with their email addresses.'
|
||||
}
|
||||
placeholder="example.com"
|
||||
name="domain"
|
||||
onChange={setDomain}
|
||||
isInvalid={domain.trim().length === 0 || isInvalid}
|
||||
/>
|
||||
{isInvalid && (
|
||||
<p className="text-red-500 text-sm mt-2">
|
||||
The domain you entered is invalid or already exists in the list.
|
||||
</p>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
|
||||
interface AddGroupProps {
|
||||
groups: string[];
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
|
||||
const [group, setGroup] = useState('');
|
||||
|
||||
const isInvalid = useMemo(() => {
|
||||
if (!group || group.trim().length === 0) {
|
||||
// Empty group is invalid, but no error shown
|
||||
return false;
|
||||
}
|
||||
|
||||
if (groups.includes(group.trim())) {
|
||||
return true;
|
||||
}
|
||||
}, [group, groups]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={isDisabled}>Add group</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Add group</Dialog.Title>
|
||||
<Dialog.Text className="mb-4">
|
||||
Add this group to a list of allowed groups that can authenticate with
|
||||
Headscale via OIDC.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="add_group" />
|
||||
<Input
|
||||
isRequired
|
||||
label="Group"
|
||||
description="The group to allow for OIDC authentication."
|
||||
placeholder="admin"
|
||||
name="group"
|
||||
onChange={setGroup}
|
||||
isInvalid={group.trim().length === 0 || isInvalid}
|
||||
/>
|
||||
{isInvalid && (
|
||||
<p className="text-red-500 text-sm mt-2">
|
||||
The group you entered already exists in the list of allowed groups.
|
||||
</p>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
|
||||
interface AddUserProps {
|
||||
users: string[];
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function AddUser({ users, isDisabled }: AddUserProps) {
|
||||
const [user, setUser] = useState('');
|
||||
|
||||
const isInvalid = useMemo(() => {
|
||||
if (!user || user.trim().length === 0) {
|
||||
// Empty user is invalid, but no error shown
|
||||
return false;
|
||||
}
|
||||
|
||||
if (users.includes(user.trim())) {
|
||||
return true;
|
||||
}
|
||||
}, [user, users]);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button isDisabled={isDisabled}>Add user</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Add user</Dialog.Title>
|
||||
<Dialog.Text className="mb-4">
|
||||
Add this user to a list of allowed users that can authenticate with
|
||||
Headscale via OIDC.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="add_user" />
|
||||
<Input
|
||||
isRequired
|
||||
label="User"
|
||||
description="The user to allow for OIDC authentication."
|
||||
placeholder="john_doe"
|
||||
name="user"
|
||||
onChange={setUser}
|
||||
isInvalid={user.trim().length === 0 || isInvalid}
|
||||
/>
|
||||
{isInvalid && (
|
||||
<p className="text-red-500 text-sm mt-2">
|
||||
The user you entered already exists in the list of allowed users.
|
||||
</p>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
Link as RemixLink,
|
||||
data,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { restrictionAction } from './actions';
|
||||
import AddDomain from './dialogs/add-domain';
|
||||
import AddGroup from './dialogs/add-group';
|
||||
import AddUser from './dialogs/add-user';
|
||||
import RestrictionTable from './table';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const check = await context.sessions.check(request, Capabilities.read_users);
|
||||
if (!check) {
|
||||
throw data('You do not have permission to view IAM settings.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.hs.c?.oidc) {
|
||||
throw data('OIDC is not configured on this Headscale instance.', {
|
||||
status: 501,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
access: await context.sessions.check(request, Capabilities.configure_iam),
|
||||
writable: context.hs.writable(),
|
||||
settings: {
|
||||
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
|
||||
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
|
||||
users: [...new Set(context.hs.c.oidc.allowed_users)],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(request: ActionFunctionArgs) {
|
||||
return restrictionAction(request);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { access, writable, settings } = useLoaderData<typeof loader>();
|
||||
const isDisabled = writable ? !access : true;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 max-w-screen-lg">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<p className="mb-4 text-md">
|
||||
<RemixLink to="/settings" className="font-medium">
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Authentication Restrictions
|
||||
</p>
|
||||
{!access ? (
|
||||
<Notice
|
||||
title="Authentication permissions restricted"
|
||||
variant="warning"
|
||||
>
|
||||
You do not have the necessary permissions to edit the Authentication
|
||||
Restrictions settings. Please contact your administrator to request
|
||||
access or to make changes to these settings.
|
||||
</Notice>
|
||||
) : !writable ? (
|
||||
<Notice title="Configuration Locked" variant="error">
|
||||
The Headscale configuration file is not editable through the web
|
||||
interface. Please ensure that you have correctly given Headplane
|
||||
write access to the file.
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<h1 className="text-2xl font-medium mb-2 mt-4">
|
||||
Authentication Restrictions
|
||||
</h1>
|
||||
<p>
|
||||
Headscale supports restricting OIDC authentication to only allow
|
||||
certain email domains, groups, or users to authenticate. This can be
|
||||
used to limit access to your Tailnet to only certain users or groups
|
||||
and Headplane will also respect these settings when authenticating.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||
name="Headscale OIDC documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RestrictionTable
|
||||
type="domain"
|
||||
values={settings.domains}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
<RestrictionTable
|
||||
type="group"
|
||||
values={settings.groups}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
<RestrictionTable
|
||||
type="user"
|
||||
values={settings.users}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<AddUser users={settings.users} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { GlobeLock, Group, User2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Form } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import TableList from '~/components/TableList';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface RestrictionProps {
|
||||
children: React.ReactNode;
|
||||
type: 'domain' | 'group' | 'user';
|
||||
values: string[];
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function RestrictionTable({
|
||||
children,
|
||||
type,
|
||||
values,
|
||||
isDisabled,
|
||||
}: RestrictionProps) {
|
||||
return (
|
||||
<div className="w-2/3">
|
||||
<h2 className="text-2xl font-medium mt-8">
|
||||
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
|
||||
</h2>
|
||||
<TableList className="my-4">
|
||||
{values.length > 0 ? (
|
||||
values.map((value) => (
|
||||
<TableList.Item key={`${type}-${value}`}>
|
||||
{type === 'domain' ? (
|
||||
<p>
|
||||
<span className="text-headplane-600 dark:text-headplane-300">
|
||||
{'<user>'}
|
||||
</span>
|
||||
<span className="font-bold">@</span>
|
||||
<span>{value}</span>
|
||||
</p>
|
||||
) : (
|
||||
<p>{value}</p>
|
||||
)}
|
||||
<Form method="POST">
|
||||
<input
|
||||
type="hidden"
|
||||
name="action_id"
|
||||
value={`remove_${type}`}
|
||||
/>
|
||||
<input type="hidden" name={type} value={value} />
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</Form>
|
||||
</TableList.Item>
|
||||
))
|
||||
) : (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
{iconForType(type)}
|
||||
<p className="font-semibold">
|
||||
All {type}s are permitted to authenticate.
|
||||
</p>
|
||||
</TableList.Item>
|
||||
)}
|
||||
</TableList>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function iconForType(type: 'domain' | 'group' | 'user') {
|
||||
if (type === 'domain') {
|
||||
return <GlobeLock />;
|
||||
}
|
||||
|
||||
if (type === 'group') {
|
||||
return <Group />;
|
||||
}
|
||||
|
||||
return <User2 />;
|
||||
}
|
||||
@@ -1,14 +1,15 @@
|
||||
import { Building2, House, Key } from 'lucide-react';
|
||||
import Card from '~/components/Card';
|
||||
import Link from '~/components/Link';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
import type { HeadplaneConfig } from '~/server/config/schema';
|
||||
import CreateUser from '../dialogs/create-user';
|
||||
|
||||
interface Props {
|
||||
oidc?: NonNullable<AppContext['context']['oidc']>;
|
||||
interface ManageBannerProps {
|
||||
oidc?: NonNullable<HeadplaneConfig['oidc']>;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ManageBanner({ oidc }: Props) {
|
||||
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
|
||||
return (
|
||||
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
@@ -60,7 +61,7 @@ export default function ManageBanner({ oidc }: Props) {
|
||||
: 'You can add, remove, and rename users here.'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<CreateUser />
|
||||
<CreateUser isOidc={oidc !== undefined} isDisabled={isDisabled} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { Ellipsis } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import Menu from '~/components/Menu';
|
||||
import type { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import Delete from '../dialogs/delete-user';
|
||||
import Reassign from '../dialogs/reassign-user';
|
||||
import Rename from '../dialogs/rename-user';
|
||||
|
||||
interface MenuProps {
|
||||
user: User & {
|
||||
headplaneRole: string;
|
||||
machines: Machine[];
|
||||
};
|
||||
}
|
||||
|
||||
type Modal = 'rename' | 'delete' | 'reassign' | null;
|
||||
|
||||
export default function UserMenu({ user }: MenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
return (
|
||||
<>
|
||||
{modal === 'rename' && (
|
||||
<Rename
|
||||
user={user}
|
||||
isOpen={modal === 'rename'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modal === 'delete' && (
|
||||
<Delete
|
||||
user={user}
|
||||
isOpen={modal === 'delete'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modal === 'reassign' && (
|
||||
<Reassign
|
||||
user={user}
|
||||
isOpen={modal === 'reassign'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : ['reassign']}>
|
||||
<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',
|
||||
)}
|
||||
>
|
||||
<Ellipsis className="h-5" />
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="rename">Rename user</Menu.Item>
|
||||
<Menu.Item key="reassign">Change role</Menu.Item>
|
||||
<Menu.Item key="delete" textValue="Delete">
|
||||
<p className="text-red-500 dark:text-red-400">Delete</p>
|
||||
</Menu.Item>
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { CircleUser } from 'lucide-react';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import MenuOptions from './menu';
|
||||
|
||||
interface UserRowProps {
|
||||
role: string;
|
||||
user: User & { machines: Machine[] };
|
||||
}
|
||||
|
||||
export default function UserRow({ user, role }: UserRowProps) {
|
||||
const isOnline = user.machines.some((machine) => machine.online);
|
||||
const lastSeen = user.machines.reduce(
|
||||
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={user.id}
|
||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
||||
>
|
||||
<td className="pl-0.5 py-2">
|
||||
<div className="flex items-center">
|
||||
{user.profilePicUrl ? (
|
||||
<img
|
||||
src={user.profilePicUrl}
|
||||
alt={user.name}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<CircleUser className="w-10 h-10" />
|
||||
)}
|
||||
<div className="ml-4">
|
||||
<p className={cn('font-semibold leading-snug')}>{user.name}</p>
|
||||
<p className="text-sm opacity-50">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="pl-0.5 py-2">
|
||||
<p>{mapRoleToName(role)}</p>
|
||||
</td>
|
||||
<td className="pl-0.5 py-2">
|
||||
<p
|
||||
suppressHydrationWarning
|
||||
className="text-sm text-headplane-600 dark:text-headplane-300"
|
||||
>
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</td>
|
||||
<td className="pl-0.5 py-2">
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 text-sm',
|
||||
'text-headplane-600 dark:text-headplane-300',
|
||||
)}
|
||||
>
|
||||
<StatusCircle isOnline={isOnline} className="w-4 h-4" />
|
||||
<p suppressHydrationWarning>
|
||||
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions user={{ ...user, headplaneRole: role }} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function mapRoleToName(role: string) {
|
||||
switch (role) {
|
||||
case 'no-oidc':
|
||||
return <p className="opacity-50">Unmanaged</p>;
|
||||
case 'invalid-oidc':
|
||||
return <p className="opacity-50">Invalid</p>;
|
||||
case 'no-role':
|
||||
return <p className="opacity-50">Unregistered</p>;
|
||||
case 'owner':
|
||||
return 'Owner';
|
||||
case 'admin':
|
||||
return 'Admin';
|
||||
case 'network_admin':
|
||||
return 'Network Admin';
|
||||
case 'it_admin':
|
||||
return 'IT Admin';
|
||||
case 'auditor':
|
||||
return 'Auditor';
|
||||
case 'member':
|
||||
return 'Member';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user