mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 16:18:57 +00:00
Compare commits
167 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 28ad1d439f | |||
| 135d2940f1 | |||
| 497a782f17 | |||
| 6d5e44f585 | |||
| 94e5cb95c6 | |||
| 800d73248d | |||
| 66b6dea068 | |||
| 489b12460f | |||
| ef8dcec9c8 | |||
| f66f778398 | |||
| 6eabfb7610 | |||
| a68df033a2 | |||
| 53b8f76ac6 | |||
| df4d864838 | |||
| c9bae42622 | |||
| a2a28e747b | |||
| d175171efc | |||
| ac9ce72078 | |||
| f44881b399 | |||
| 3b93f2fb61 | |||
| 1b2d7e305a | |||
| 639e7e9c6b | |||
| 5d94667186 | |||
| 225dfceddb | |||
| cd379cd10d | |||
| 8f260b79f1 | |||
| eaffcd7b62 | |||
| 4ccc73d7e4 | |||
| 7a222c47ee | |||
| cb9d0d5317 | |||
| bb0d24b708 | |||
| ebe487daf0 | |||
| 4fd8062476 | |||
| 9967ef9e4c | |||
| 43cbc177d6 | |||
| 0eef9aea9b | |||
| 9b53711673 | |||
| 341a0e8f81 | |||
| cee28fba2f | |||
| 65f56842de | |||
| 4803b2aa4a | |||
| 1dfb2fb777 | |||
| 07c5c6d496 | |||
| 4df15fd4b2 | |||
| 5b138c191a | |||
| 957157de5f | |||
| 9f0564943b | |||
| 2fcd19e2b4 | |||
| eb4669498a | |||
| bda9dedbfd | |||
| a9d5e10be9 | |||
| bd8a7a56d4 | |||
| d7b1e19985 | |||
| 9bc8483a8f | |||
| 4351e1fcb3 | |||
| 9183f805a6 | |||
| cd4e8f8399 | |||
| 6c5656334d | |||
| ff3bdc1898 | |||
| 356abab90a | |||
| 82f6294499 | |||
| 8fc657f86a | |||
| bcd87453bd | |||
| 7adacae940 | |||
| 797ed56c07 | |||
| ae2bd35cbf | |||
| d2c4f5eb2b | |||
| 8cb91cd45b | |||
| a4a037ed68 | |||
| 17712cbb1c | |||
| 616b63bb71 | |||
| 361bc493c0 | |||
| bc3fc5ec5d | |||
| c98dc5ed1d | |||
| 9192013bfe | |||
| fdbfe584c5 | |||
| 97e2557710 | |||
| 82a3eb626e | |||
| cdcb38f11e | |||
| f20b2c869f | |||
| 1355e1535d | |||
| ebd219b622 | |||
| 1ce0dc375e | |||
| 1eaf3abe21 | |||
| 8a9235836a | |||
| 344901d38c | |||
| ea004df2bb | |||
| c59632a0b7 | |||
| 89e38e1356 | |||
| d257b22f91 | |||
| 441e31690b | |||
| e0fde7d0f4 | |||
| 943e5d2a15 | |||
| 7f952bcb3b | |||
| c3112507b5 | |||
| 8b3d0e9bb6 | |||
| 7a34a01dd0 | |||
| 51a85e1bfe | |||
| 5ab9686460 | |||
| 5cfd9e411b | |||
| 65a14aefcf | |||
| f0c1f617c7 | |||
| 44577aff81 | |||
| 27b2831f00 | |||
| ad469129bc | |||
| 7691f74d43 | |||
| f7547a4c62 | |||
| ce4617d0e1 | |||
| 6d99aca058 | |||
| d809eea564 | |||
| eacde3deb8 | |||
| fc7bedc8b1 | |||
| ecc547601f | |||
| eed12d348e | |||
| 5a254b746b | |||
| 2cc4339499 | |||
| 84c820efb1 | |||
| 779af42601 | |||
| f990670e86 | |||
| 0344f17887 | |||
| f28bfd5f86 | |||
| 8f1b577b5e | |||
| 6ea9fe169d | |||
| 9a81b76398 | |||
| 144a8b87cd | |||
| 2bf088b4a9 | |||
| 87b8d64bcc | |||
| 8819af270d | |||
| 1150d1616e | |||
| dd287c0444 | |||
| cf55621b57 | |||
| 73f0a0d273 | |||
| 71f130ede5 | |||
| c0af2aa9c8 | |||
| b18147fa82 | |||
| bf1d75a27a | |||
| 1e86b0e95b | |||
| b34a3f0ca1 | |||
| 799d7b5728 | |||
| 4961e83a9c | |||
| ab7587e7a9 | |||
| 6d15c411f1 | |||
| 7f376c3f70 | |||
| 6a0e097412 | |||
| 721439868f | |||
| f4af5b920d | |||
| 0f9bf73b82 | |||
| 7a6ad8d2d5 | |||
| f9751f5ab2 | |||
| 46f1576b8f | |||
| ccde3513dc | |||
| a0a80852eb | |||
| cb32637938 | |||
| 55eacb59e9 | |||
| 7dfcbef774 | |||
| f6d8ad25e1 | |||
| e36193a8c0 | |||
| 0c87f3e6ec | |||
| 090f5c1ba9 | |||
| 9b9d5432c6 | |||
| 2a26a74b65 | |||
| a298686d6b | |||
| 2f316176c8 | |||
| 59525b7b63 | |||
| b7009e387b | |||
| e173376319 | |||
| 59d7f15694 |
+8
-4
@@ -1,7 +1,11 @@
|
|||||||
node_modules
|
|
||||||
.dockerignore
|
|
||||||
Dockerfile
|
|
||||||
|
|
||||||
/.cache
|
/.cache
|
||||||
|
/.github
|
||||||
|
/.react-router
|
||||||
|
/node_modules
|
||||||
/build
|
/build
|
||||||
|
/docs
|
||||||
|
/nix
|
||||||
|
|
||||||
|
.dockerignore
|
||||||
.env
|
.env
|
||||||
|
Dockerfile
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
* @tale
|
||||||
|
|
||||||
|
/nix @tale @StealthBadger747
|
||||||
@@ -28,20 +28,15 @@ jobs:
|
|||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Install node.js
|
- name: Setup Mise
|
||||||
uses: actions/setup-node@v4
|
uses: jdx/mise-action@v2
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
|
|
||||||
- uses: pnpm/action-setup@v4
|
- name: Set caching paths
|
||||||
name: Install pnpm
|
|
||||||
with:
|
|
||||||
run_install: false
|
|
||||||
|
|
||||||
- name: Get pnpm store directory
|
|
||||||
shell: bash
|
shell: bash
|
||||||
run: |
|
run: |
|
||||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||||
|
echo "GO_CACHE=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||||
|
echo "GO_MODCACHE=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
- uses: actions/cache@v4
|
||||||
name: Setup pnpm cache
|
name: Setup pnpm cache
|
||||||
@@ -51,11 +46,16 @@ jobs:
|
|||||||
restore-keys: |
|
restore-keys: |
|
||||||
${{ runner.os }}-pnpm-store-
|
${{ runner.os }}-pnpm-store-
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Setup Go cache
|
||||||
run: pnpm install
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
${{ env.GO_CACHE }}
|
||||||
|
${{ env.GO_MODCACHE }}
|
||||||
|
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod', '**/go.sum') }}
|
||||||
|
|
||||||
- name: Build
|
- name: CI pipeline
|
||||||
run: pnpm build
|
run: ./build.sh --skip-pnpm-prune
|
||||||
|
|
||||||
nix:
|
nix:
|
||||||
name: nix
|
name: nix
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ permissions:
|
|||||||
actions: write # Allow canceling in-progress runs
|
actions: write # Allow canceling in-progress runs
|
||||||
contents: read # Read access to the repository
|
contents: read # Read access to the repository
|
||||||
packages: write # Write access to the container registry
|
packages: write # Write access to the container registry
|
||||||
|
id-token: write # For the attest action to push
|
||||||
|
attestations: write # For the attest action to push
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
publish:
|
publish:
|
||||||
@@ -19,6 +21,14 @@ jobs:
|
|||||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
|
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
|
name: Docker Pre-release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: final
|
||||||
|
tag: 'next'
|
||||||
|
- target: debug-shell
|
||||||
|
tag: 'next-shell'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -29,7 +39,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository }}
|
images: ghcr.io/${{ github.repository }}
|
||||||
tags: |
|
tags: |
|
||||||
type=raw,value=next
|
type=raw,value=${{ matrix.tag }}
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -44,12 +54,28 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and publish ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
|
id: push
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
target: ${{ matrix.target }}
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
platforms: linux/amd64, linux/arm64
|
platforms: linux/amd64, linux/arm64
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
build-args: |
|
||||||
|
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||||
|
secrets: |
|
||||||
|
gh_token=${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Attestation Provenance for ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
id: attest
|
||||||
|
with:
|
||||||
|
subject-name: ghcr.io/${{ github.repository }}
|
||||||
|
subject-digest: ${{ steps.push.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
|
|||||||
@@ -12,11 +12,21 @@ permissions:
|
|||||||
actions: write # Allow canceling in-progress runs
|
actions: write # Allow canceling in-progress runs
|
||||||
contents: read # Read access to the repository
|
contents: read # Read access to the repository
|
||||||
packages: write # Write access to the container registry
|
packages: write # Write access to the container registry
|
||||||
|
id-token: write # For the attest action to push
|
||||||
|
attestations: write # For the attest action to push
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker:
|
docker:
|
||||||
name: Docker Release
|
name: Docker Release
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
matrix:
|
||||||
|
include:
|
||||||
|
- target: final
|
||||||
|
tag_suffix: ''
|
||||||
|
- target: debug-shell
|
||||||
|
tag_suffix: '-shell'
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -28,6 +38,9 @@ jobs:
|
|||||||
images: ghcr.io/${{ github.repository }}
|
images: ghcr.io/${{ github.repository }}
|
||||||
tags: |
|
tags: |
|
||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
flavor: |
|
||||||
|
latest=auto
|
||||||
|
suffix=${{ matrix.tag_suffix }},onlatest=true
|
||||||
|
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
@@ -42,12 +55,25 @@ jobs:
|
|||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v6
|
||||||
|
id: push
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
|
target: ${{ matrix.target }}
|
||||||
push: true
|
push: true
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
platforms: linux/amd64, linux/arm64
|
platforms: linux/amd64, linux/arm64
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
build-args: |
|
||||||
|
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||||
|
- name: Attestation Provenance for ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
id: attest
|
||||||
|
with:
|
||||||
|
subject-name: ghcr.io/${{ github.repository }}
|
||||||
|
subject-digest: ${{ steps.push.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
|
|||||||
@@ -4,3 +4,12 @@ node_modules
|
|||||||
/build
|
/build
|
||||||
/test
|
/test
|
||||||
.env
|
.env
|
||||||
|
/app/hp_ssh.wasm
|
||||||
|
/app/wasm_exec.js
|
||||||
|
|
||||||
|
/public/hp_ssh.wasm
|
||||||
|
/public/wasm_exec.js
|
||||||
|
|
||||||
|
/docs/.vitepress/dist/
|
||||||
|
/docs/.vitepress/cache/
|
||||||
|
/.direnv
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
pnpm 10.4.0
|
|
||||||
node 22
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
{
|
|
||||||
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+147
-37
@@ -1,4 +1,42 @@
|
|||||||
### 0.6.0 (May 25, 2025)
|
# 0.6.2 (Next)
|
||||||
|
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
|
||||||
|
- Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)).
|
||||||
|
- Implemented the ability to customize the build with a custom script (see `./build.sh --help` for more information).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0.6.1 (October 12, 2025)
|
||||||
|
- **Headplane now supports connecting to machines via SSH in the web browser.**
|
||||||
|
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
|
||||||
|
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
|
||||||
|
- Begin using a new SQLite database file in `/var/lib/headplane/hp_persist.db`.
|
||||||
|
- The database is created automatically if it does not exist.
|
||||||
|
- It currently stores SSH connection details and HostInfo for the agent.
|
||||||
|
- User information is automatically migrated from the previous database.
|
||||||
|
- The docker container now runs in a distroless image (closes [#255](https://github.com/tale/headplane/issues/255)).
|
||||||
|
- A debug version of the container that runs as root and has a shell is available as `ghcr.io/tale/headplane:<version>-shell`.
|
||||||
|
- Removing a Split DNS record will no longer make the split domain unresolvable by clients (closes [#231](https://github.com/tale/headplane/issues/231)).
|
||||||
|
- Reintroduce the toggle for overriding local DNS settings in the Headscale config (closes [#236](https://github.com/tale/headplane/issues/236)).
|
||||||
|
- Prefer cross-compiling in the Dockerfile to speed up builds while still supporting multiple architectures.
|
||||||
|
- Add a build attestation to validate SLSA provenance for the Docker image.
|
||||||
|
- Implement more accurate guessing on the PID with the `/proc` integration (via [#219](https://github.com/tale/headplane/pull/219)).
|
||||||
|
- Usernames will now correctly fall back to emails if not provided (via [#257](https://github.com/tale/headplane/pull/257)).
|
||||||
|
- Configuration loading via paths is now supported for sensitive values (via [#283](https://github.com/tale/headplane/pulls/283))
|
||||||
|
- Options like `server.cookie_secret_path` can override `server.cookie_secret`
|
||||||
|
- Environment variables are interpolatable into these paths
|
||||||
|
- See the full reference in the [docs](https://github.com/tale/headplane/blob/main/docs/Configuration.md#sensitive-values)
|
||||||
|
- The nix overlay build is fixed for the SSH module (via [#282](https://github.com/tale/headplane/pull/282))
|
||||||
|
- Switch our build processes to use TypeScript Go and Rolldown Vite for better build and type-check performance.
|
||||||
|
- Cookies are now encrypted JWTs, preserving API key secrets (*GHSA-wrqq-v7qw-r5w7*)
|
||||||
|
- OIDC profile pictures are now available from Gravatar by setting `oidc.profile_picture_source` to `gravatar` (closes [#232](https://github.com/tale/headplane/issues/232)).
|
||||||
|
- OIDC now allows passing many custom parameters:
|
||||||
|
- `oidc.authorization_endpoint`, `oidc.token_endpoint`, and `oidc.userinfo_endpoint` can be overridden to support non-standard providers or scenarios without discovery (closes [#117](https://github.com/tale/headplane/issues/117)).
|
||||||
|
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
|
||||||
|
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 0.6.0 (May 25, 2025)
|
||||||
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
|
- 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)).
|
- 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.
|
- Older versions of Headscale will not work with Headplane.
|
||||||
@@ -23,15 +61,21 @@
|
|||||||
- Tighten `proc` integration logic by checking for the `headscale serve` command (via #[195](https://github.com/tale/headplane/pull/195)).
|
- 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)).
|
- 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)
|
---
|
||||||
|
|
||||||
|
# 0.5.10 (April 4, 2025)
|
||||||
- Fix an issue where other preferences to skip onboarding affected every user.
|
- Fix an issue where other preferences to skip onboarding affected every user.
|
||||||
|
|
||||||
### 0.5.9 (April 3, 2025)
|
---
|
||||||
|
|
||||||
|
# 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.
|
- 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.
|
- 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.
|
- Suppress hydration errors for any fields with a date in it.
|
||||||
|
|
||||||
### 0.5.8 (April 3, 2025)
|
---
|
||||||
|
|
||||||
|
# 0.5.8 (April 3, 2025)
|
||||||
- You can now skip the onboarding page if desired.
|
- You can now skip the onboarding page if desired.
|
||||||
- Added the UI to change user roles in the dashboard.
|
- Added the UI to change user roles in the dashboard.
|
||||||
- Fixed an issue where integrations would throw instead of loading properly.
|
- Fixed an issue where integrations would throw instead of loading properly.
|
||||||
@@ -39,10 +83,14 @@
|
|||||||
- Automatically create `/var/lib/headplane` in the Docker container (fixes [#166](https://github.com/tale/headplane/issues/166))
|
- 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))
|
- OIDC logout with `disable_api_key_login` set to true will not automatically login again (fixes [#149](https://github.com/tale/headplane/issues/149))
|
||||||
|
|
||||||
### 0.5.7 (April 2, 2025)
|
---
|
||||||
|
|
||||||
|
# 0.5.7 (April 2, 2025)
|
||||||
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
|
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
|
||||||
|
|
||||||
### 0.5.6 (April 2, 2025)
|
---
|
||||||
|
|
||||||
|
# 0.5.6 (April 2, 2025)
|
||||||
|
|
||||||
### IMPORTANT
|
### IMPORTANT
|
||||||
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously *ANY* accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
|
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously *ANY* accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
|
||||||
@@ -62,10 +110,14 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
- Unknown values in the Headplane config no longer cause a crash.
|
- 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))
|
- Fixed an issue that caused copied commands to have a random space (fixes [#161](https://github.com/tale/headplane/issues/161))
|
||||||
|
|
||||||
### 0.5.5 (March 18, 2025)
|
---
|
||||||
|
|
||||||
|
# 0.5.5 (March 18, 2025)
|
||||||
- Hotfix an issue that caused Headplane to crash if no agents are available
|
- Hotfix an issue that caused Headplane to crash if no agents are available
|
||||||
|
|
||||||
### 0.5.4 (March 18, 2025)
|
---
|
||||||
|
|
||||||
|
# 0.5.4 (March 18, 2025)
|
||||||
- Fixed a typo in the Kubernetes documentation
|
- 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))
|
- 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))
|
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
|
||||||
@@ -79,20 +131,28 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
- You can now pass in an OIDC client secret through `oidc.client_secret_path` in the config (fixes [#126](https://github.com/tale/headplane/issues/126))
|
- 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))
|
- Correctly handle differently localized number inputs (fixes [#125](https://github.com/tale/headplane/issues/125))
|
||||||
|
|
||||||
### 0.5.3 (March 1, 2025)
|
---
|
||||||
|
|
||||||
|
# 0.5.3 (March 1, 2025)
|
||||||
- Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111))
|
- 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))
|
- 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))
|
- Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112))
|
||||||
|
|
||||||
### 0.5.2 (February 28, 2025)
|
---
|
||||||
|
|
||||||
|
# 0.5.2 (February 28, 2025)
|
||||||
- Hotfixed an issue where the server bundle got reloaded on each request
|
- Hotfixed an issue where the server bundle got reloaded on each request
|
||||||
|
|
||||||
### 0.5.1 (February 28, 2025)
|
---
|
||||||
|
|
||||||
|
# 0.5.1 (February 28, 2025)
|
||||||
- Fixed an issue that caused the entire server to crash on start
|
- Fixed an issue that caused the entire server to crash on start
|
||||||
- Fixed the published semver tags from Docker
|
- Fixed the published semver tags from Docker
|
||||||
- Fixed the Kubernetes integration not reading the config
|
- Fixed the Kubernetes integration not reading the config
|
||||||
|
|
||||||
### 0.5 (February 27, 2025)
|
---
|
||||||
|
|
||||||
|
# 0.5 (February 27, 2025)
|
||||||
> This release is a major overhaul and contains a significant breaking change.
|
> 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.
|
> 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.
|
> Please see [config.example.yaml](/config.example.yaml) for the new format.
|
||||||
@@ -107,10 +167,14 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
- Settings that were previously shared like `public_url` or `oidc` are now separate within Headplane/Headscale. This is a rather large breaking change but fixes cases where a user may choose to utilize Headscale OIDC for Tailscale but not for the Headplane UI.
|
- Settings that were previously shared like `public_url` or `oidc` are now separate within Headplane/Headscale. This is a rather large breaking change but fixes cases where a user may choose to utilize Headscale OIDC for Tailscale but not for the Headplane UI.
|
||||||
- Deprecate the `latest` tag in Docker for explicit versioning and `edge` for nightly builds.
|
- Deprecate the `latest` tag in Docker for explicit versioning and `edge` for nightly builds.
|
||||||
|
|
||||||
### 0.4.1 (January 18, 2025)
|
---
|
||||||
|
|
||||||
|
# 0.4.1 (January 18, 2025)
|
||||||
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
|
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
|
||||||
|
|
||||||
### 0.4.0 (January 18, 2025)
|
---
|
||||||
|
|
||||||
|
# 0.4.0 (January 18, 2025)
|
||||||
- Switched from Remix.run to React-Router
|
- Switched from Remix.run to React-Router
|
||||||
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
|
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
|
||||||
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
|
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
|
||||||
@@ -123,29 +187,41 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
- Reusable Pre-Auth Keys no longer show expired when used (PR [#88](https://github.com/tale/headplane/pull/88))
|
- Reusable Pre-Auth Keys no longer show expired when used (PR [#88](https://github.com/tale/headplane/pull/88))
|
||||||
- Tweaked some CSS issues in the UI
|
- Tweaked some CSS issues in the UI
|
||||||
|
|
||||||
### 0.3.9 (December 6, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.3.9 (December 6, 2024)
|
||||||
- Fixed a race condition bug in the OIDC validation code
|
- Fixed a race condition bug in the OIDC validation code
|
||||||
|
|
||||||
### 0.3.8 (December 6, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.3.8 (December 6, 2024)
|
||||||
- Added a little HTML footer to show the login page and link to a donation page.
|
- Added a little HTML footer to show the login page and link to a donation page.
|
||||||
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
|
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
|
||||||
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
|
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
|
||||||
|
|
||||||
### 0.3.7 (November 30, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.3.7 (November 30, 2024)
|
||||||
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
|
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
|
||||||
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
|
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
|
||||||
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
|
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
|
||||||
- Fixed an issue where the copy machine registration command had a typo.
|
- Fixed an issue where the copy machine registration command had a typo.
|
||||||
|
|
||||||
### 0.3.6 (November 20, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.3.6 (November 20, 2024)
|
||||||
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
|
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
|
||||||
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
|
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
|
||||||
|
|
||||||
### 0.3.5 (November 8, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.3.5 (November 8, 2024)
|
||||||
- Quickfix a bug where environment variables are ignored on the server.
|
- Quickfix a bug where environment variables are ignored on the server.
|
||||||
- Remove a nagging error about missing cookie since that happens when signed out.
|
- Remove a nagging error about missing cookie since that happens when signed out.
|
||||||
|
|
||||||
### 0.3.4 (November 7, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.3.4 (November 7, 2024)
|
||||||
- Clicking on the machine name in the users page now takes you to the machine overview page.
|
- Clicking on the machine name in the users page now takes you to the machine overview page.
|
||||||
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
|
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
|
||||||
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
|
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
|
||||||
@@ -155,70 +231,94 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
|
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
|
||||||
- Added a basic check to see if the API keys passed via cookies are invalid.
|
- Added a basic check to see if the API keys passed via cookies are invalid.
|
||||||
|
|
||||||
### 0.3.3 (October 28, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.3.3 (October 28, 2024)
|
||||||
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
|
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
|
||||||
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
|
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
|
||||||
- Improved ACL editor performance by switching back to CodeMirror 6
|
- Improved ACL editor performance by switching back to CodeMirror 6
|
||||||
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
|
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
|
||||||
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
|
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
|
||||||
|
|
||||||
### 0.3.2 (October 11, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.3.2 (October 11, 2024)
|
||||||
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
|
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
|
||||||
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
|
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
|
||||||
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
|
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
|
||||||
- Handle `null` values on machine expiry when using a database like PostgreSQL.
|
- Handle `null` values on machine expiry when using a database like PostgreSQL.
|
||||||
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
|
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
|
||||||
|
|
||||||
### 0.3.1 (October 3, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.3.1 (October 3, 2024)
|
||||||
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
|
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
|
||||||
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
|
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
|
||||||
- Added the option for debug logs with the `DEBUG=true` environment variable.
|
- Added the option for debug logs with the `DEBUG=true` environment variable.
|
||||||
|
|
||||||
### 0.3.0 (September 25, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.3.0 (September 25, 2024)
|
||||||
- Bumped the minimum supported version of Headscale to 0.23.
|
- Bumped the minimum supported version of Headscale to 0.23.
|
||||||
- Updated the UI to respect `dns.use_username_in_magic_dns`.
|
- Updated the UI to respect `dns.use_username_in_magic_dns`.
|
||||||
|
|
||||||
### 0.2.4 (August 24, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.2.4 (August 24, 2024)
|
||||||
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
|
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
|
||||||
- Removed the `ACL_FILE` environment variable since it's no longer needed.
|
- Removed the `ACL_FILE` environment variable since it's no longer needed.
|
||||||
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
|
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
|
||||||
- Fixed a bug where removing Split DNS configurations would crash the UI.
|
- Fixed a bug where removing Split DNS configurations would crash the UI.
|
||||||
|
|
||||||
### 0.2.3 (August 23, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.2.3 (August 23, 2024)
|
||||||
- Change the minimum required version of Headscale to 0.23-beta2
|
- Change the minimum required version of Headscale to 0.23-beta2
|
||||||
- Support the new API policy mode for Headscale 0.23-beta1
|
- Support the new API policy mode for Headscale 0.23-beta1
|
||||||
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
|
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
|
||||||
- If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24))
|
- If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24))
|
||||||
|
|
||||||
### 0.2.2 (August 2, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.2.2 (August 2, 2024)
|
||||||
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
|
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
|
||||||
- Added a new logger utility that shows categories, levels, and timestamps.
|
- Added a new logger utility that shows categories, levels, and timestamps.
|
||||||
- Reimplemented the integration system to be more resilient and log more information.
|
- Reimplemented the integration system to be more resilient and log more information.
|
||||||
- Fixed an issue where the /proc integration found `undefined` PIDs.
|
- Fixed an issue where the /proc integration found `undefined` PIDs.
|
||||||
|
|
||||||
### 0.2.1 (July 7, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.2.1 (July 7, 2024)
|
||||||
- Added the ability to manage custom DNS records on your Tailnet.
|
- Added the ability to manage custom DNS records on your Tailnet.
|
||||||
- ACL tags for machines are now able to be changed via the machine menu.
|
- ACL tags for machines are now able to be changed via the machine menu.
|
||||||
- Fixed a bug where the ACL editor did not show the diffs correctly.
|
- Fixed a bug where the ACL editor did not show the diffs correctly.
|
||||||
- Fixed an issue that stopped the "Discard changes" button in the ACL editor from working.
|
- Fixed an issue that stopped the "Discard changes" button in the ACL editor from working.
|
||||||
|
|
||||||
### 0.2.0 (June 23, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.2.0 (June 23, 2024)
|
||||||
- Fix the dropdown options for machines not working on the machines page.
|
- Fix the dropdown options for machines not working on the machines page.
|
||||||
- Add an option to change the machine owner in the dropdown (aside from the users page).
|
- Add an option to change the machine owner in the dropdown (aside from the users page).
|
||||||
|
|
||||||
### 0.1.9 (June 2, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.1.9 (June 2, 2024)
|
||||||
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
|
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
|
||||||
- Utilize magic DNS hostnames for the machine overview page.
|
- Utilize magic DNS hostnames for the machine overview page.
|
||||||
- Fixed the expiry issue once and for all.
|
- Fixed the expiry issue once and for all.
|
||||||
- Add a nightly build with the `ghcr.io/tale/headplane:edge` tag
|
- Add a nightly build with the `ghcr.io/tale/headplane:edge` tag
|
||||||
|
|
||||||
### 0.1.8 (June 2, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.1.8 (June 2, 2024)
|
||||||
- Built basic functionality for the machine overview page (by machine ID).
|
- Built basic functionality for the machine overview page (by machine ID).
|
||||||
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
|
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
|
||||||
- Prevent users from being deleted if they still have ownership of machines.
|
- Prevent users from being deleted if they still have ownership of machines.
|
||||||
- Fixed some type issues where `Date` was being used instead of `string` for timestamps.
|
- Fixed some type issues where `Date` was being used instead of `string` for timestamps.
|
||||||
|
|
||||||
### 0.1.7 (May 30, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.1.7 (May 30, 2024)
|
||||||
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
|
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
|
||||||
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
|
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
|
||||||
- Made the strict configuration loader more lenient to allow for more flexibility.
|
- Made the strict configuration loader more lenient to allow for more flexibility.
|
||||||
@@ -226,19 +326,25 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
- Headplane's context now only loads once at start instead of being lazy-loaded.
|
- Headplane's context now only loads once at start instead of being lazy-loaded.
|
||||||
- Improved logging and error propagation so that it's easier to debug issues.
|
- Improved logging and error propagation so that it's easier to debug issues.
|
||||||
|
|
||||||
### 0.1.6 (May 22, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.1.6 (May 22, 2024)
|
||||||
- Added experimental support for advanced integration without Docker.
|
- Added experimental support for advanced integration without Docker.
|
||||||
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
|
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
|
||||||
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
|
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
|
||||||
|
|
||||||
### 0.1.5 (May 20, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.1.5 (May 20, 2024)
|
||||||
- Robust configuration handling with fallbacks based on the headscale source.
|
- Robust configuration handling with fallbacks based on the headscale source.
|
||||||
- Support for `client_secret_path` on configuration file based OIDC.
|
- Support for `client_secret_path` on configuration file based OIDC.
|
||||||
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
|
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
|
||||||
- `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works).
|
- `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works).
|
||||||
- Fixed button responders not actually being invoked (should fix the ACL page).
|
- Fixed button responders not actually being invoked (should fix the ACL page).
|
||||||
|
|
||||||
### 0.1.4 (May 15, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.1.4 (May 15, 2024)
|
||||||
|
|
||||||
- Users can now be created, renamed, and deleted on the users page.
|
- Users can now be created, renamed, and deleted on the users page.
|
||||||
- Machines can be dragged between users to change their ownership.
|
- Machines can be dragged between users to change their ownership.
|
||||||
@@ -247,13 +353,17 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
- Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues).
|
- Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues).
|
||||||
- Upgrade other dependencies
|
- Upgrade other dependencies
|
||||||
|
|
||||||
### 0.1.3 (May 4, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.1.3 (May 4, 2024)
|
||||||
|
|
||||||
- Switched to a better icon set for the UI.
|
- Switched to a better icon set for the UI.
|
||||||
- Support stable scrollbar gutter if supported by the browser.
|
- Support stable scrollbar gutter if supported by the browser.
|
||||||
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
|
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
|
||||||
|
|
||||||
### 0.1.2 (May 1, 2024)
|
---
|
||||||
|
|
||||||
|
# 0.1.2 (May 1, 2024)
|
||||||
|
|
||||||
- Added support for renaming, expiring, removing, and managing the routes of a machine.
|
- Added support for renaming, expiring, removing, and managing the routes of a machine.
|
||||||
- Implemented an expiry check for machines which now reflect on the machine table.
|
- Implemented an expiry check for machines which now reflect on the machine table.
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
https://localhost {
|
||||||
|
reverse_proxy headscale:8080
|
||||||
|
tls /certs/localhost.pem /certs/localhost-key.pem
|
||||||
|
|
||||||
|
header {
|
||||||
|
Access-Control-Allow-Origin *
|
||||||
|
Access-Control-Allow-Headers *
|
||||||
|
Access-Control-Allow-Methods GET, POST, OPTIONS
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-26
@@ -1,35 +1,66 @@
|
|||||||
FROM golang:1.24 AS agent-build
|
FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
|
||||||
WORKDIR /app
|
WORKDIR /run
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
COPY go.mod go.sum build.sh ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY agent/ ./agent
|
COPY cmd/ ./cmd/
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
COPY internal/ ./internal/
|
||||||
-trimpath \
|
|
||||||
-ldflags "-s -w" \
|
|
||||||
-o /app/hp_agent ./agent/cmd/hp_agent
|
|
||||||
|
|
||||||
FROM node:22-alpine AS build
|
ARG TARGETOS
|
||||||
WORKDIR /app
|
ARG TARGETARCH
|
||||||
|
ARG IMAGE_TAG
|
||||||
|
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 IMAGE_TAG=$IMAGE_TAG \
|
||||||
|
./build.sh --wasm --agent --fake-shell \
|
||||||
|
--wasm-output /bin/hp_ssh.wasm \
|
||||||
|
--agent-output /bin/hp_agent \
|
||||||
|
--fake-shell-output /bin/fake-sh
|
||||||
|
|
||||||
RUN npm install -g pnpm@10
|
RUN chmod +x /bin/hp_ssh.wasm
|
||||||
RUN apk add --no-cache git
|
RUN chmod +x /bin/hp_agent
|
||||||
COPY package.json pnpm-lock.yaml ./
|
RUN chmod +x /bin/fake-sh
|
||||||
COPY patches ./patches
|
|
||||||
RUN pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
COPY . .
|
# Folder needs to exist for later stages
|
||||||
RUN pnpm run build
|
|
||||||
|
|
||||||
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
|
RUN mkdir -p /var/lib/headplane/agent
|
||||||
|
|
||||||
|
FROM --platform=$BUILDPLATFORM node:22.16-slim AS js-base
|
||||||
|
WORKDIR /run
|
||||||
|
|
||||||
|
RUN corepack enable
|
||||||
|
COPY patches ./patches
|
||||||
|
COPY package.json pnpm-lock.yaml build.sh ./
|
||||||
|
|
||||||
|
COPY --from=go-base /bin/hp_ssh.wasm /run/app/hp_ssh.wasm
|
||||||
|
COPY --from=go-base /bin/wasm_exec.js /run/app/wasm_exec.js
|
||||||
|
RUN ./build.sh --app --app-install-only
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN ./build.sh --app
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/nodejs22-debian12:latest AS final
|
||||||
|
COPY --from=js-base /run/build /app/build
|
||||||
|
COPY --from=js-base /run/drizzle /app/drizzle
|
||||||
|
COPY --from=js-base /run/node_modules /app/node_modules
|
||||||
|
|
||||||
|
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
|
||||||
|
COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
||||||
|
|
||||||
|
# Fake shell to inform the user that they should use the debug image
|
||||||
|
COPY --from=go-base /bin/fake-sh /bin/sh
|
||||||
|
COPY --from=go-base /bin/fake-sh /bin/bash
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=build /app/build /app/build
|
CMD [ "/app/build/server/index.js" ]
|
||||||
COPY --from=agent-build /app/hp_agent /usr/libexec/headplane/agent
|
|
||||||
RUN chmod +x /usr/libexec/headplane/agent
|
FROM node:22-alpine AS debug-shell
|
||||||
CMD [ "node", "./build/server/index.js" ]
|
RUN apk add --no-cache bash curl
|
||||||
|
|
||||||
|
COPY --from=js-base /run/build /app/build
|
||||||
|
COPY --from=js-base /run/drizzle /app/drizzle
|
||||||
|
COPY --from=js-base /run/node_modules /app/node_modules
|
||||||
|
|
||||||
|
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
|
||||||
|
COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
CMD [ "node", "/app/build/server/index.js" ]
|
||||||
|
|||||||
@@ -4,15 +4,15 @@
|
|||||||
<picture>
|
<picture>
|
||||||
<source
|
<source
|
||||||
media="(prefers-color-scheme: dark)"
|
media="(prefers-color-scheme: dark)"
|
||||||
srcset="./assets/preview-dark.png"
|
srcset="./docs/assets/preview-dark.png"
|
||||||
>
|
>
|
||||||
<source
|
<source
|
||||||
media="(prefers-color-scheme: light)"
|
media="(prefers-color-scheme: light)"
|
||||||
srcset="./assets/preview-light.png"
|
srcset="./docs/assets/preview-light.png"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
src="./assets/preview-dark.png"
|
src="./docs/assets/preview-dark.png"
|
||||||
>
|
>
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
@@ -32,24 +32,14 @@ These are some of the features that Headplane offers:
|
|||||||
- Configurability for Headscale's settings
|
- Configurability for Headscale's settings
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
Headplane runs as a server-based web-application, meaning you'll need a server to run it.
|
Refer to the [website](https://headplane.net) for detailed installation instructions.
|
||||||
It's available as a Docker image (recommended) or through a manual installation.
|
|
||||||
There are 2 ways to deploy Headplane:
|
|
||||||
|
|
||||||
- ### [Integrated Mode (Recommended)](/docs/Integrated-Mode.md)
|
## Versioning
|
||||||
Integrated mode unlocks all the features of Headplane and is the most
|
|
||||||
feature-complete deployment method. It communicates with Headscale directly.
|
|
||||||
|
|
||||||
- ### [Simple Mode](/docs/Simple-Mode.md)
|
|
||||||
Simple mode does not include the automatic management of DNS and Headplane
|
|
||||||
settings, requiring manual editing and reloading when making changes.
|
|
||||||
|
|
||||||
### Versioning
|
|
||||||
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
|
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
|
Pre-release builds are available under the `next` tag and get updated when a new release
|
||||||
PR is opened and actively in testing.
|
PR is opened and actively in testing.
|
||||||
|
|
||||||
### Contributing
|
## Contributing
|
||||||
Headplane is an open-source project and contributions are welcome! If you have
|
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
|
any suggestions, bug reports, or feature requests, please open an issue. Also
|
||||||
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
|
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
|
||||||
@@ -59,30 +49,30 @@ refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
|
|||||||
<picture>
|
<picture>
|
||||||
<source
|
<source
|
||||||
media="(prefers-color-scheme: dark)"
|
media="(prefers-color-scheme: dark)"
|
||||||
srcset="./assets/acls-dark.png"
|
srcset="./docs/assets/acls-dark.png"
|
||||||
>
|
>
|
||||||
<source
|
<source
|
||||||
media="(prefers-color-scheme: light)"
|
media="(prefers-color-scheme: light)"
|
||||||
srcset="./assets/acls-light.png"
|
srcset="./docs/assets/acls-light.png"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
alt="ACLs"
|
alt="ACLs"
|
||||||
src="./assets/acls-dark.png"
|
src="./docs/assets/acls-dark.png"
|
||||||
>
|
>
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
<source
|
<source
|
||||||
media="(prefers-color-scheme: dark)"
|
media="(prefers-color-scheme: dark)"
|
||||||
srcset="./assets/machine-dark.png"
|
srcset="./docs/assets/machine-dark.png"
|
||||||
>
|
>
|
||||||
<source
|
<source
|
||||||
media="(prefers-color-scheme: light)"
|
media="(prefers-color-scheme: light)"
|
||||||
srcset="./assets/machine-light.png"
|
srcset="./docs/assets/machine-light.png"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
alt="Machine Management"
|
alt="Machine Management"
|
||||||
src="./assets/machine-dark.png"
|
src="./docs/assets/machine-dark.png"
|
||||||
>
|
>
|
||||||
</picture>
|
</picture>
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
FROM golang:1.23 AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
COPY agent/ ./agent
|
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
|
||||||
-trimpath \
|
|
||||||
-ldflags "-s -w" \
|
|
||||||
-o /app/hp_agent ./agent/cmd/hp_agent
|
|
||||||
|
|
||||||
FROM scratch
|
|
||||||
COPY --from=builder /app/hp_agent /hp_agent
|
|
||||||
ENTRYPOINT ["/hp_agent"]
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
_ "github.com/joho/godotenv/autoload"
|
|
||||||
"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.Fatal("Failed to load config: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.SetDebug(cfg.Debug)
|
|
||||||
agent := tsnet.NewAgent(cfg)
|
|
||||||
|
|
||||||
agent.Connect()
|
|
||||||
defer agent.Shutdown()
|
|
||||||
|
|
||||||
log.Msg(&Register{
|
|
||||||
Type: "register",
|
|
||||||
ID: agent.ID,
|
|
||||||
})
|
|
||||||
|
|
||||||
hpagent.FollowMaster(agent)
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
package tsnet
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"github.com/tale/headplane/agent/internal/util"
|
|
||||||
"tailscale.com/tailcfg"
|
|
||||||
"tailscale.com/types/key"
|
|
||||||
|
|
||||||
"go4.org/mem"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Returns the raw hostinfo for a peer based on node ID.
|
|
||||||
func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -38,7 +38,7 @@ export default function Attribute({
|
|||||||
'min-w-0 px-1.5 py-1 rounded-lg border border-transparent',
|
'min-w-0 px-1.5 py-1 rounded-lg border border-transparent',
|
||||||
...(isCopyable
|
...(isCopyable
|
||||||
? [
|
? [
|
||||||
'cursor-pointer hover:shadow-sm',
|
'cursor-pointer hover:shadow-xs',
|
||||||
'hover:bg-headplane-50 dark:hover:bg-headplane-800',
|
'hover:bg-headplane-50 dark:hover:bg-headplane-800',
|
||||||
'hover:border-headplane-100 dark:hover:border-headplane-700',
|
'hover:border-headplane-100 dark:hover:border-headplane-700',
|
||||||
]
|
]
|
||||||
@@ -70,8 +70,8 @@ export default function Attribute({
|
|||||||
</div>
|
</div>
|
||||||
{isCopyable ? (
|
{isCopyable ? (
|
||||||
<div>
|
<div>
|
||||||
<Check className="size-4 hidden data-[copied]:block" />
|
<Check className="size-4 hidden data-copied:block" />
|
||||||
<Copy className="size-4 block data-[copied]:hidden" />
|
<Copy className="size-4 block data-copied:hidden" />
|
||||||
</div>
|
</div>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export default function Button({ variant = 'light', ...props }: ButtonProps) {
|
|||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-fit text-sm rounded-xl px-3 py-2',
|
'w-fit text-sm rounded-xl px-3 py-2',
|
||||||
'focus:outline-none focus:ring',
|
'focus:outline-hidden focus:ring-3',
|
||||||
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
||||||
...(variant === 'heavy'
|
...(variant === 'heavy'
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function Card({ variant = 'raised', ...props }: Props) {
|
|||||||
'w-full max-w-md rounded-3xl p-5',
|
'w-full max-w-md rounded-3xl p-5',
|
||||||
variant === 'flat'
|
variant === 'flat'
|
||||||
? 'bg-transparent shadow-none'
|
? 'bg-transparent shadow-none'
|
||||||
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-sm',
|
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-xs',
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
'border border-headplane-100 dark:border-headplane-800',
|
||||||
props.className,
|
props.className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
|
|||||||
<code
|
<code
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
|
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
|
||||||
'rounded-lg focus-within:outline-none focus-within:ring-2',
|
'rounded-lg focus-within:outline-hidden focus-within:ring-2',
|
||||||
isCopyable && 'relative pr-7',
|
isCopyable && 'relative pr-7',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -21,7 +21,6 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
|
|||||||
{children}
|
{children}
|
||||||
{isCopyable && (
|
{isCopyable && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
|
||||||
className="bottom-0 right-0 absolute"
|
className="bottom-0 right-0 absolute"
|
||||||
onClick={async (event) => {
|
onClick={async (event) => {
|
||||||
const text = Array.isArray(children) ? children.join('') : children;
|
const text = Array.isArray(children) ? children.join('') : children;
|
||||||
@@ -40,9 +39,10 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
|
|||||||
}
|
}
|
||||||
}, 1000);
|
}, 1000);
|
||||||
}}
|
}}
|
||||||
|
type="button"
|
||||||
>
|
>
|
||||||
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
|
<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" />
|
<Copy className="h-4.5 w-4.5 p-1 block data-copied:hidden" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</code>
|
</code>
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ function Panel(props: DialogPanelProps) {
|
|||||||
method={method ?? 'POST'}
|
method={method ?? 'POST'}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'outline-none rounded-3xl w-full max-w-lg',
|
'outline-hidden rounded-3xl w-full max-w-lg',
|
||||||
'bg-white dark:bg-headplane-900',
|
'bg-white dark:bg-headplane-900',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { AlertIcon } from '@primer/octicons-react';
|
import { AlertCircle } from 'lucide-react';
|
||||||
import { isRouteErrorResponse, useRouteError } from 'react-router';
|
import { isRouteErrorResponse, useRouteError } from 'react-router';
|
||||||
import ResponseError from '~/server/headscale/api-error';
|
import ResponseError from '~/server/headscale/api-error';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
import Card from './Card';
|
import Card from './Card';
|
||||||
|
import Code from './Code';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
type?: 'full' | 'embedded';
|
type?: 'full' | 'embedded';
|
||||||
@@ -69,14 +70,16 @@ export function ErrorPopup({ type = 'full' }: Props) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-4">
|
||||||
<Card.Title className="text-3xl mb-0">
|
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||||
{routing ? error.status : title}
|
<div className="flex justify-between items-center gap-2 w-full">
|
||||||
</Card.Title>
|
<Card.Title className="text-3xl mb-0">{title}</Card.Title>
|
||||||
<AlertIcon className="w-12 h-12 text-red-500" />
|
{routing && <Code className="text-2xl">{`${error.status}`}</Code>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<hr className="my-4 text-headplane-100 dark:text-headplane-800" />
|
||||||
<Card.Text
|
<Card.Text
|
||||||
className={cn('mt-4 text-lg', routing ? 'font-normal' : 'font-mono')}
|
className={cn('py-4 text-lg', routing ? 'font-normal' : 'font-mono')}
|
||||||
>
|
>
|
||||||
{routing ? error.data : message}
|
{routing ? error.data : message}
|
||||||
</Card.Text>
|
</Card.Text>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function Footer({ url, debug, healthy }: FooterProps) {
|
|||||||
tabIndex={0} // Allows keyboard focus
|
tabIndex={0} // Allows keyboard focus
|
||||||
className={cn(
|
className={cn(
|
||||||
'blur-sm hover:blur-none focus:blur-none transition',
|
'blur-sm hover:blur-none focus:blur-none transition',
|
||||||
'focus:outline-none focus:ring-2 rounded-sm',
|
'focus:outline-hidden focus:ring-2 rounded-xs',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{url}
|
{url}
|
||||||
|
|||||||
+22
-22
@@ -2,13 +2,13 @@ import {
|
|||||||
CircleUser,
|
CircleUser,
|
||||||
Globe2,
|
Globe2,
|
||||||
Lock,
|
Lock,
|
||||||
PlaneTakeoff,
|
|
||||||
Server,
|
Server,
|
||||||
Settings,
|
Settings,
|
||||||
Users,
|
Users,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
import { NavLink, useSubmit } from 'react-router';
|
import { NavLink, useSubmit } from 'react-router';
|
||||||
|
import Logo from '~/components/Logo';
|
||||||
import Menu from '~/components/Menu';
|
import Menu from '~/components/Menu';
|
||||||
import { AuthSession } from '~/server/web/sessions';
|
import { AuthSession } from '~/server/web/sessions';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
@@ -42,18 +42,18 @@ function TabLink({ name, to, icon }: TabLinkProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative py-2">
|
<div className="relative py-2">
|
||||||
<NavLink
|
<NavLink
|
||||||
to={to}
|
|
||||||
prefetch="intent"
|
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
cn(
|
cn(
|
||||||
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5',
|
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5',
|
||||||
'after:absolute after:bottom-0 after:left-3 after:right-3',
|
'after:absolute after:bottom-0 after:left-3 after:right-3',
|
||||||
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
|
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
|
||||||
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
|
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
|
||||||
'focus:outline-none focus:ring',
|
'focus:outline-hidden focus:ring-3',
|
||||||
isActive ? 'after:visible' : 'after:invisible',
|
isActive ? 'after:visible' : 'after:invisible',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
prefetch="intent"
|
||||||
|
to={to}
|
||||||
>
|
>
|
||||||
{icon} {name}
|
{icon} {name}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -64,13 +64,13 @@ function TabLink({ name, to, icon }: TabLinkProps) {
|
|||||||
function Link({ href, text }: LinkProps) {
|
function Link({ href, text }: LinkProps) {
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
href={href}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'hidden sm:block hover:underline text-sm',
|
'hidden sm:block hover:underline text-sm',
|
||||||
'focus:outline-none focus:ring rounded-md',
|
'focus:outline-hidden focus:ring-3 rounded-md',
|
||||||
)}
|
)}
|
||||||
|
href={href}
|
||||||
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
>
|
>
|
||||||
{text}
|
{text}
|
||||||
</a>
|
</a>
|
||||||
@@ -91,7 +91,7 @@ export default function Header(data: Props) {
|
|||||||
>
|
>
|
||||||
<div className="container flex items-center justify-between py-4">
|
<div className="container flex items-center justify-between py-4">
|
||||||
<div className="flex items-center gap-x-2">
|
<div className="flex items-center gap-x-2">
|
||||||
<PlaneTakeoff />
|
<Logo />
|
||||||
<h1 className="text-2xl font-semibold">headplane</h1>
|
<h1 className="text-2xl font-semibold">headplane</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-x-4">
|
<div className="flex items-center gap-x-4">
|
||||||
@@ -101,20 +101,21 @@ export default function Header(data: Props) {
|
|||||||
{data.user ? (
|
{data.user ? (
|
||||||
<Menu>
|
<Menu>
|
||||||
<Menu.IconButton
|
<Menu.IconButton
|
||||||
label="User"
|
|
||||||
className={cn(data.user.picture ? 'p-0' : '')}
|
className={cn(data.user.picture ? 'p-0' : '')}
|
||||||
|
label="User"
|
||||||
>
|
>
|
||||||
{data.user.picture ? (
|
{data.user.picture ? (
|
||||||
<img
|
<img
|
||||||
src={data.user.picture}
|
|
||||||
alt={data.user.name}
|
alt={data.user.name}
|
||||||
className="w-8 h-8 rounded-full"
|
className="w-8 h-8 rounded-full"
|
||||||
|
src={data.user.picture}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CircleUser />
|
<CircleUser />
|
||||||
)}
|
)}
|
||||||
</Menu.IconButton>
|
</Menu.IconButton>
|
||||||
<Menu.Panel
|
<Menu.Panel
|
||||||
|
disabledKeys={['profile']}
|
||||||
onAction={(key) => {
|
onAction={(key) => {
|
||||||
if (key === 'logout') {
|
if (key === 'logout') {
|
||||||
submit(
|
submit(
|
||||||
@@ -126,7 +127,6 @@ export default function Header(data: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
disabledKeys={['profile']}
|
|
||||||
>
|
>
|
||||||
<Menu.Section>
|
<Menu.Section>
|
||||||
<Menu.Item key="profile" textValue="Profile">
|
<Menu.Item key="profile" textValue="Profile">
|
||||||
@@ -148,39 +148,39 @@ export default function Header(data: Props) {
|
|||||||
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
|
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
|
||||||
{data.access.machines ? (
|
{data.access.machines ? (
|
||||||
<TabLink
|
<TabLink
|
||||||
to="/machines"
|
|
||||||
name="Machines"
|
|
||||||
icon={<Server className="w-5" />}
|
icon={<Server className="w-5" />}
|
||||||
|
name="Machines"
|
||||||
|
to="/machines"
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{data.access.users ? (
|
{data.access.users ? (
|
||||||
<TabLink
|
<TabLink
|
||||||
to="/users"
|
|
||||||
name="Users"
|
|
||||||
icon={<Users className="w-5" />}
|
icon={<Users className="w-5" />}
|
||||||
|
name="Users"
|
||||||
|
to="/users"
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{data.access.policy ? (
|
{data.access.policy ? (
|
||||||
<TabLink
|
<TabLink
|
||||||
to="/acls"
|
|
||||||
name="Access Control"
|
|
||||||
icon={<Lock className="w-5" />}
|
icon={<Lock className="w-5" />}
|
||||||
|
name="Access Control"
|
||||||
|
to="/acls"
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{data.configAvailable ? (
|
{data.configAvailable ? (
|
||||||
<>
|
<>
|
||||||
{data.access.dns ? (
|
{data.access.dns ? (
|
||||||
<TabLink
|
<TabLink
|
||||||
to="/dns"
|
|
||||||
name="DNS"
|
|
||||||
icon={<Globe2 className="w-5" />}
|
icon={<Globe2 className="w-5" />}
|
||||||
|
name="DNS"
|
||||||
|
to="/dns"
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
{data.access.settings ? (
|
{data.access.settings ? (
|
||||||
<TabLink
|
<TabLink
|
||||||
to="/settings"
|
|
||||||
name="Settings"
|
|
||||||
icon={<Settings className="w-5" />}
|
icon={<Settings className="w-5" />}
|
||||||
|
name="Settings"
|
||||||
|
to="/settings"
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function IconButton({
|
|||||||
aria-label={props.label}
|
aria-label={props.label}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-full flex items-center justify-center p-1',
|
'rounded-full flex items-center justify-center p-1',
|
||||||
'focus:outline-none focus:ring',
|
'focus:outline-hidden focus:ring-3',
|
||||||
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
props.isDisabled && 'opacity-60 cursor-not-allowed',
|
||||||
...(variant === 'heavy'
|
...(variant === 'heavy'
|
||||||
? [
|
? [
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export default function Input(props: InputProps) {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl px-3 py-2',
|
'rounded-xl px-3 py-2',
|
||||||
'focus:outline-none focus:ring',
|
'focus:outline-hidden focus:ring-3',
|
||||||
'bg-white dark:bg-headplane-900',
|
'bg-white dark:bg-headplane-900',
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
'border border-headplane-100 dark:border-headplane-800',
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export default function Link({
|
|||||||
'inline-flex items-center gap-x-0.5',
|
'inline-flex items-center gap-x-0.5',
|
||||||
'text-blue-500 hover:text-blue-700',
|
'text-blue-500 hover:text-blue-700',
|
||||||
'dark:text-blue-400 dark:hover:text-blue-300',
|
'dark:text-blue-400 dark:hover:text-blue-300',
|
||||||
'focus:outline-none focus:ring rounded-md',
|
'focus:outline-hidden focus:ring-3 rounded-md',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import cn from '~/utils/cn';
|
||||||
|
import LogoSvg from '../../public/logo-light.svg';
|
||||||
|
|
||||||
|
export interface LogoProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Logo({ className }: LogoProps) {
|
||||||
|
return <img alt="Logo" className={cn(className)} src={LogoSvg} />;
|
||||||
|
}
|
||||||
@@ -74,7 +74,7 @@ function Panel(props: MenuPanelProps) {
|
|||||||
<ul
|
<ul
|
||||||
{...menuProps}
|
{...menuProps}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
|
className="pt-1 pb-1 shadow-2xs rounded-md min-w-[200px] focus:outline-hidden"
|
||||||
>
|
>
|
||||||
{[...state.collection].map((item) => (
|
{[...state.collection].map((item) => (
|
||||||
<MenuSection
|
<MenuSection
|
||||||
@@ -149,7 +149,7 @@ function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'py-2 px-3 mx-1 rounded-lg',
|
'py-2 px-3 mx-1 rounded-lg',
|
||||||
'focus:outline-none select-none',
|
'focus:outline-hidden select-none',
|
||||||
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
|
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
|
||||||
isDisabled
|
isDisabled
|
||||||
? 'text-headplane-400 dark:text-headplane-600'
|
? 'text-headplane-400 dark:text-headplane-600'
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ export default function NumberInput(props: InputProps) {
|
|||||||
{...groupProps}
|
{...groupProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-1 rounded-xl pr-1',
|
'flex items-center gap-1 rounded-xl pr-1',
|
||||||
'focus-within:outline-none focus-within:ring',
|
'focus-within:outline-hidden focus-within:ring-3',
|
||||||
'bg-white dark:bg-headplane-900',
|
'bg-white dark:bg-headplane-900',
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
'border border-headplane-100 dark:border-headplane-800',
|
||||||
)}
|
)}
|
||||||
@@ -60,7 +60,7 @@ export default function NumberInput(props: InputProps) {
|
|||||||
required={props.isRequired}
|
required={props.isRequired}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
id={id}
|
id={id}
|
||||||
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-none"
|
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-hidden"
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name={name} value={state.numberValue} />
|
<input type="hidden" name={name} value={state.numberValue} />
|
||||||
<IconButton
|
<IconButton
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ function Option({ item, state }: OptionsOptionProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
|
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
|
||||||
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
|
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
|
||||||
'focus:outline-none focus:ring z-10',
|
'focus:outline-hidden focus:ring-3 z-10',
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
'border border-headplane-100 dark:border-headplane-800',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function Popover(props: PopoverProps) {
|
|||||||
{...popoverProps}
|
{...popoverProps}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'z-10 shadow-sm rounded-xl',
|
'z-10 shadow-xs rounded-xl',
|
||||||
'bg-white dark:bg-headplane-900',
|
'bg-white dark:bg-headplane-900',
|
||||||
'border border-headplane-200 dark:border-headplane-800',
|
'border border-headplane-200 dark:border-headplane-800',
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ function Select(props: SelectProps) {
|
|||||||
</label>
|
</label>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex rounded-xl focus:outline-none focus-within:ring',
|
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
|
||||||
'bg-white dark:bg-headplane-900',
|
'bg-white dark:bg-headplane-900',
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
'border border-headplane-100 dark:border-headplane-800',
|
||||||
)}
|
)}
|
||||||
@@ -69,7 +69,7 @@ function Select(props: SelectProps) {
|
|||||||
{...inputProps}
|
{...inputProps}
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
id={id}
|
id={id}
|
||||||
className="outline-none px-3 py-2 rounded-l-xl w-full bg-transparent"
|
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
|
||||||
data-1p-ignore
|
data-1p-ignore
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
@@ -127,7 +127,7 @@ function ListBox(props: ListBoxProps) {
|
|||||||
<ul
|
<ul
|
||||||
{...listBoxProps}
|
{...listBoxProps}
|
||||||
ref={listBoxRef}
|
ref={listBoxRef}
|
||||||
className="w-full max-h-72 overflow-auto outline-none pt-1"
|
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
|
||||||
>
|
>
|
||||||
{[...state.collection].map((item) => (
|
{[...state.collection].map((item) => (
|
||||||
<Option key={item.key} item={item} state={state} />
|
<Option key={item.key} item={item} state={state} />
|
||||||
@@ -158,7 +158,7 @@ function Option({ item, state }: OptionProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between',
|
'flex items-center justify-between',
|
||||||
'py-2 px-3 mx-1 rounded-lg mb-1',
|
'py-2 px-3 mx-1 rounded-lg mb-1',
|
||||||
'focus:outline-none select-none',
|
'focus:outline-hidden select-none',
|
||||||
isFocused || isSelected
|
isFocused || isSelected
|
||||||
? 'bg-headplane-100/50 dark:bg-headplane-800'
|
? 'bg-headplane-100/50 dark:bg-headplane-800'
|
||||||
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
|
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import cn from '~/utils/cn';
|
|||||||
export interface SwitchProps extends AriaSwitchProps {
|
export interface SwitchProps extends AriaSwitchProps {
|
||||||
label: string;
|
label: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
switchClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Switch(props: SwitchProps) {
|
export default function Switch(props: SwitchProps) {
|
||||||
@@ -45,14 +46,16 @@ export default function Switch(props: SwitchProps) {
|
|||||||
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
|
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
|
||||||
isFocusVisible && 'ring-2',
|
isFocusVisible && 'ring-2',
|
||||||
props.isDisabled && 'opacity-50',
|
props.isDisabled && 'opacity-50',
|
||||||
|
props.className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-[18px] w-[18px] transform rounded-full',
|
'h-[18px] w-[18px] transform rounded-full',
|
||||||
'bg-white transition duration-50 ease-in-out',
|
'bg-white transition duration-50 ease-in-out',
|
||||||
'translate-x-0 group-selected:translate-x-[100%]',
|
'translate-x-0 group-selected:translate-x-full',
|
||||||
state.isSelected && 'translate-x-[100%]',
|
state.isSelected && 'translate-x-full',
|
||||||
|
props.switchClassName,
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ function Tab({ item, state }: TabsTabProps) {
|
|||||||
className={cn(
|
className={cn(
|
||||||
'pl-2 pr-3 py-2.5',
|
'pl-2 pr-3 py-2.5',
|
||||||
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
|
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
|
||||||
'focus:outline-none focus:ring z-10',
|
'focus:outline-hidden focus:ring-3 z-10',
|
||||||
'border-r border-headplane-100 dark:border-headplane-800',
|
'border-r border-headplane-100 dark:border-headplane-800',
|
||||||
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
|
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ function Tooltip(props: TooltipProps) {
|
|||||||
{...triggerProps}
|
{...triggerProps}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center',
|
'flex items-center justify-center',
|
||||||
'focus:outline-none focus:ring rounded-xl',
|
'focus:outline-hidden focus:ring-3 rounded-xl',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{component}
|
{component}
|
||||||
@@ -65,7 +65,7 @@ function Body({ state, className, ...props }: TooltipBodyProps) {
|
|||||||
{...mergeProps(props, tooltipProps)}
|
{...mergeProps(props, tooltipProps)}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute z-50 p-3 top-full mt-1',
|
'absolute z-50 p-3 top-full mt-1',
|
||||||
'outline-none rounded-3xl text-sm w-48',
|
'outline-hidden rounded-3xl text-sm w-48',
|
||||||
'bg-white dark:bg-headplane-950',
|
'bg-white dark:bg-headplane-950',
|
||||||
'text-black dark:text-white',
|
'text-black dark:text-white',
|
||||||
'shadow-lg dark:shadow-md rounded-xl',
|
'shadow-lg dark:shadow-md rounded-xl',
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import cn from '~/utils/cn';
|
||||||
|
import Chip from '../Chip';
|
||||||
|
import Tooltip from '../Tooltip';
|
||||||
|
|
||||||
|
export function TailscaleSSHTag() {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<Chip
|
||||||
|
text="Tailscale SSH"
|
||||||
|
className={cn(
|
||||||
|
'bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<Tooltip.Body>
|
||||||
|
This machine advertises Tailscale SSH, which allows you to authenticate
|
||||||
|
SSH credentials using your Tailscale account and via the Headplane web
|
||||||
|
UI.
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
+10
-19
@@ -1,37 +1,30 @@
|
|||||||
import { XCircleFillIcon } from '@primer/octicons-react';
|
import { type LoaderFunctionArgs, Outlet, redirect } from 'react-router';
|
||||||
import { ServerCrash } from 'lucide-react';
|
import { ErrorPopup } from '~/components/Error';
|
||||||
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 type { LoadContext } from '~/server';
|
||||||
|
import { pruneEphemeralNodes } from '~/server/db/pruner';
|
||||||
import ResponseError from '~/server/headscale/api-error';
|
import ResponseError from '~/server/headscale/api-error';
|
||||||
import cn from '~/utils/cn';
|
|
||||||
import log from '~/utils/log';
|
import log from '~/utils/log';
|
||||||
|
|
||||||
export async function loader({
|
export async function loader({
|
||||||
request,
|
request,
|
||||||
context,
|
context,
|
||||||
|
...rest
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
}: LoaderFunctionArgs<LoadContext>) {
|
||||||
const healthy = await context.client.healthcheck();
|
const healthy = await context.client.healthcheck();
|
||||||
const session = await context.sessions.auth(request);
|
const session = await context.sessions.auth(request);
|
||||||
|
await pruneEphemeralNodes({ context, request, ...rest });
|
||||||
|
|
||||||
// We shouldn't session invalidate if Headscale is down
|
// 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
|
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
|
||||||
if (healthy) {
|
if (healthy) {
|
||||||
try {
|
try {
|
||||||
await context.client.get('v1/apikey', session.get('api_key')!);
|
await context.client.get('v1/apikey', session.api_key);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ResponseError) {
|
if (error instanceof ResponseError) {
|
||||||
log.debug('api', 'API Key validation failed %o', error);
|
log.debug('api', 'API Key validation failed %o', error);
|
||||||
return redirect('/login', {
|
return redirect('/login', {
|
||||||
headers: {
|
headers: {
|
||||||
'Set-Cookie': await context.sessions.destroy(session),
|
'Set-Cookie': await context.sessions.destroySession(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -45,11 +38,9 @@ export async function loader({
|
|||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return (
|
return (
|
||||||
<>
|
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
<Outlet />
|
||||||
<Outlet />
|
</main>
|
||||||
</main>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+19
-70
@@ -1,3 +1,4 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import { CircleCheckIcon } from 'lucide-react';
|
import { CircleCheckIcon } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
LoaderFunctionArgs,
|
LoaderFunctionArgs,
|
||||||
@@ -10,9 +11,8 @@ import Card from '~/components/Card';
|
|||||||
import Footer from '~/components/Footer';
|
import Footer from '~/components/Footer';
|
||||||
import Header from '~/components/Header';
|
import Header from '~/components/Header';
|
||||||
import type { LoadContext } from '~/server';
|
import type { LoadContext } from '~/server';
|
||||||
|
import { users } from '~/server/db/schema';
|
||||||
import { Capabilities } from '~/server/web/roles';
|
import { Capabilities } from '~/server/web/roles';
|
||||||
import { User } from '~/types';
|
|
||||||
import log from '~/utils/log';
|
|
||||||
import toast from '~/utils/toast';
|
import toast from '~/utils/toast';
|
||||||
|
|
||||||
// This loads the bare minimum for the application to function
|
// This loads the bare minimum for the application to function
|
||||||
@@ -23,72 +23,18 @@ export async function loader({
|
|||||||
}: LoaderFunctionArgs<LoadContext>) {
|
}: LoaderFunctionArgs<LoadContext>) {
|
||||||
try {
|
try {
|
||||||
const session = await context.sessions.auth(request);
|
const session = await context.sessions.auth(request);
|
||||||
if (!session.has('api_key')) {
|
if (
|
||||||
// There is a session, but it's not valid
|
context.oidc &&
|
||||||
return redirect('/login', {
|
session.user.subject !== 'unknown-non-oauth' &&
|
||||||
headers: {
|
!request.url.endsWith('/onboarding')
|
||||||
'Set-Cookie': await context.sessions.destroy(session),
|
) {
|
||||||
},
|
const [user] = await context.db
|
||||||
});
|
.select()
|
||||||
}
|
.from(users)
|
||||||
|
.where(eq(users.sub, session.user.subject))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
// Onboarding is only a feature of the OIDC flow
|
if (!user?.onboarded) {
|
||||||
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');
|
return redirect('/onboarding');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,7 +45,7 @@ export async function loader({
|
|||||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||||
configAvailable: context.hs.readable(),
|
configAvailable: context.hs.readable(),
|
||||||
debug: context.config.debug,
|
debug: context.config.debug,
|
||||||
user: session.get('user'),
|
user: session.user,
|
||||||
uiAccess: check,
|
uiAccess: check,
|
||||||
access: {
|
access: {
|
||||||
ui: await context.sessions.check(request, Capabilities.ui_access),
|
ui: await context.sessions.check(request, Capabilities.ui_access),
|
||||||
@@ -119,8 +65,11 @@ export async function loader({
|
|||||||
healthy: await context.client.healthcheck(),
|
healthy: await context.client.healthcheck(),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
// No session, so we can just return
|
return redirect('/login', {
|
||||||
return redirect('/login');
|
headers: {
|
||||||
|
'Set-Cookie': await context.sessions.destroySession(),
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+4
-1
@@ -8,6 +8,7 @@ import {
|
|||||||
useNavigation,
|
useNavigation,
|
||||||
} from 'react-router';
|
} from 'react-router';
|
||||||
import '@fontsource-variable/inter';
|
import '@fontsource-variable/inter';
|
||||||
|
import { ExternalScripts } from 'remix-utils/external-scripts';
|
||||||
import { ErrorPopup } from '~/components/Error';
|
import { ErrorPopup } from '~/components/Error';
|
||||||
import ProgressBar from '~/components/ProgressBar';
|
import ProgressBar from '~/components/ProgressBar';
|
||||||
import ToastProvider from '~/components/ToastProvider';
|
import ToastProvider from '~/components/ToastProvider';
|
||||||
@@ -38,15 +39,17 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
|
<link href="favicon.ico" rel="icon" />
|
||||||
</head>
|
</head>
|
||||||
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
|
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
|
||||||
{children}
|
{children}
|
||||||
<ToastProvider queue={toastQueue} />
|
<ToastProvider queue={toastQueue} />
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
|
<ExternalScripts />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
</LiveDataProvider>
|
</LiveDataProvider>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export default [
|
|||||||
route('/logout', 'routes/auth/logout.ts'),
|
route('/logout', 'routes/auth/logout.ts'),
|
||||||
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
|
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
|
||||||
route('/oidc/start', 'routes/auth/oidc-start.ts'),
|
route('/oidc/start', 'routes/auth/oidc-start.ts'),
|
||||||
|
route('/ssh', 'routes/ssh/console.tsx'),
|
||||||
|
|
||||||
// All the main logged-in dashboard routes
|
// All the main logged-in dashboard routes
|
||||||
// Double nested to separate error propagations
|
// Double nested to separate error propagations
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export async function aclAction({
|
|||||||
const { policy, updatedAt } = await context.client.put<{
|
const { policy, updatedAt } = await context.client.put<{
|
||||||
policy: string;
|
policy: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}>('v1/policy', session.get('api_key')!, {
|
}>('v1/policy', session.api_key, {
|
||||||
policy: policyData,
|
policy: policyData,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export async function aclLoader({
|
|||||||
const { policy, updatedAt } = await context.client.get<{
|
const { policy, updatedAt } = await context.client.get<{
|
||||||
policy: string;
|
policy: string;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
}>('v1/policy', session.get('api_key')!);
|
}>('v1/policy', session.api_key);
|
||||||
|
|
||||||
// Successfully loaded the policy, mark it as readable
|
// Successfully loaded the policy, mark it as readable
|
||||||
// If `updatedAt` is null, it means the policy is in file mode.
|
// If `updatedAt` is null, it means the policy is in file mode.
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ export default function Page() {
|
|||||||
The ACL policy mode is most likely set to <Code>file</Code> in your
|
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
|
Headscale configuration. This means that the ACL file cannot be edited
|
||||||
through the web interface. In order to resolve this, you'll need to
|
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
|
set <Code>policy.mode</Code> to <Code>database</Code> in your
|
||||||
configuration.
|
Headscale configuration.
|
||||||
</Notice>
|
</Notice>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
|
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
|
||||||
|
|||||||
@@ -60,25 +60,23 @@ export async function loginAction({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set the session
|
|
||||||
const session = await context.sessions.getOrCreate(request);
|
|
||||||
const expiresDays = Math.round(
|
const expiresDays = Math.round(
|
||||||
(expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24,
|
(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', {
|
return redirect('/machines', {
|
||||||
headers: {
|
headers: {
|
||||||
'Set-Cookie': await context.sessions.commit(session, {
|
'Set-Cookie': await context.sessions.createSession(
|
||||||
maxAge: expiry.getTime() - Date.now(),
|
{
|
||||||
}),
|
api_key: apiKey,
|
||||||
|
user: {
|
||||||
|
subject: 'unknown-non-oauth',
|
||||||
|
name: `${lookup.prefix}...`,
|
||||||
|
email: `expires@${expiresDays.toString()}-days`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
expiry.getTime() - Date.now(),
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
ActionFunctionArgs,
|
ActionFunctionArgs,
|
||||||
|
data,
|
||||||
Form,
|
Form,
|
||||||
LoaderFunctionArgs,
|
LoaderFunctionArgs,
|
||||||
Link as RemixLink,
|
Link as RemixLink,
|
||||||
data,
|
|
||||||
redirect,
|
redirect,
|
||||||
useActionData,
|
useActionData,
|
||||||
useLoaderData,
|
useLoaderData,
|
||||||
@@ -24,10 +24,8 @@ export async function loader({
|
|||||||
context,
|
context,
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
}: LoaderFunctionArgs<LoadContext>) {
|
||||||
try {
|
try {
|
||||||
const session = await context.sessions.auth(request);
|
await context.sessions.auth(request);
|
||||||
if (session.has('api_key')) {
|
return redirect('/machines');
|
||||||
return redirect('/machines');
|
|
||||||
}
|
|
||||||
} catch {}
|
} catch {}
|
||||||
|
|
||||||
const qp = new URL(request.url).searchParams;
|
const qp = new URL(request.url).searchParams;
|
||||||
@@ -104,26 +102,26 @@ export default function Page() {
|
|||||||
terminal.
|
terminal.
|
||||||
</Card.Text>
|
</Card.Text>
|
||||||
<Input
|
<Input
|
||||||
|
className="mt-8 mb-2"
|
||||||
isRequired
|
isRequired
|
||||||
labelHidden
|
|
||||||
label="API Key"
|
label="API Key"
|
||||||
|
labelHidden
|
||||||
name="api_key"
|
name="api_key"
|
||||||
placeholder="API Key"
|
placeholder="API Key"
|
||||||
type="password"
|
type="password"
|
||||||
className="mt-8 mb-2"
|
|
||||||
/>
|
/>
|
||||||
{formData?.success === false ? (
|
{formData?.success === false ? (
|
||||||
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
|
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
|
||||||
{formData.message}
|
{formData.message}
|
||||||
</Card.Text>
|
</Card.Text>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<Button className="w-full" variant="heavy" type="submit">
|
<Button className="w-full" type="submit" variant="heavy">
|
||||||
Sign In
|
Sign In
|
||||||
</Button>
|
</Button>
|
||||||
</Form>
|
</Form>
|
||||||
{oidc ? (
|
{oidc ? (
|
||||||
<RemixLink to="/oidc/start">
|
<RemixLink to="/oidc/start">
|
||||||
<Button variant="light" className="w-full mt-2">
|
<Button className="w-full mt-2" variant="light">
|
||||||
Single Sign-On
|
Single Sign-On
|
||||||
</Button>
|
</Button>
|
||||||
</RemixLink>
|
</RemixLink>
|
||||||
|
|||||||
@@ -9,9 +9,10 @@ export async function action({
|
|||||||
request,
|
request,
|
||||||
context,
|
context,
|
||||||
}: ActionFunctionArgs<LoadContext>) {
|
}: ActionFunctionArgs<LoadContext>) {
|
||||||
const session = await context.sessions.auth(request);
|
try {
|
||||||
if (!session.has('api_key')) {
|
await context.sessions.auth(request);
|
||||||
return redirect('/login');
|
} catch {
|
||||||
|
redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
// When API key is disabled, we need to explicitly redirect
|
// When API key is disabled, we need to explicitly redirect
|
||||||
@@ -22,7 +23,7 @@ export async function action({
|
|||||||
|
|
||||||
return redirect(url, {
|
return redirect(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'Set-Cookie': await context.sessions.destroy(session),
|
'Set-Cookie': await context.sessions.destroySession(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { count, eq } from 'drizzle-orm';
|
||||||
|
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
|
||||||
|
import { ulid } from 'ulidx';
|
||||||
import type { LoadContext } from '~/server';
|
import type { LoadContext } from '~/server';
|
||||||
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions';
|
import { HeadplaneConfig } from '~/server/config/schema';
|
||||||
import { finishAuthFlow, formatError } from '~/utils/oidc';
|
import { users } from '~/server/db/schema';
|
||||||
|
import { Roles } from '~/server/web/roles';
|
||||||
|
import { FlowUser, finishAuthFlow, formatError } from '~/utils/oidc';
|
||||||
import { send } from '~/utils/res';
|
import { send } from '~/utils/res';
|
||||||
|
|
||||||
|
interface OidcFlowSession {
|
||||||
|
state: string;
|
||||||
|
nonce: string;
|
||||||
|
code_verifier: string;
|
||||||
|
redirect_uri: string;
|
||||||
|
}
|
||||||
|
|
||||||
export async function loader({
|
export async function loader({
|
||||||
request,
|
request,
|
||||||
context,
|
context,
|
||||||
@@ -18,13 +30,21 @@ export async function loader({
|
|||||||
return redirect('/login');
|
return redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
|
const cookie = createCookie('__oidc_auth_flow', {
|
||||||
if (session.get('state') !== 'flow') {
|
httpOnly: true,
|
||||||
return redirect('/login'); // Haven't started an OIDC flow
|
maxAge: 300, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: OidcFlowSession | null = await cookie.parse(
|
||||||
|
request.headers.get('Cookie'),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data === null) {
|
||||||
|
console.warn('OIDC flow session not found');
|
||||||
|
return redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = session.get('oidc')!;
|
const { code_verifier, state, nonce, redirect_uri } = data;
|
||||||
const { code_verifier, state, nonce, redirect_uri } = payload;
|
|
||||||
if (!code_verifier || !state || !nonce || !redirect_uri) {
|
if (!code_verifier || !state || !nonce || !redirect_uri) {
|
||||||
return send({ error: 'Missing OIDC state' }, { status: 400 });
|
return send({ error: 'Missing OIDC state' }, { status: 400 });
|
||||||
}
|
}
|
||||||
@@ -42,20 +62,39 @@ export async function loader({
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const user = await finishAuthFlow(context.oidc, flowOptions);
|
let user = await finishAuthFlow(context.oidc, flowOptions);
|
||||||
session.unset('oidc');
|
user = {
|
||||||
const userSession = session as Session<AuthSession>;
|
...user,
|
||||||
|
picture: setOidcPictureForSource(
|
||||||
|
user,
|
||||||
|
context.config.oidc?.profile_picture_source ?? 'oidc',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
const [{ count: userCount }] = await context.db
|
||||||
|
.select({ count: count() })
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.caps, Roles.owner));
|
||||||
|
|
||||||
|
await context.db
|
||||||
|
.insert(users)
|
||||||
|
.values({
|
||||||
|
id: ulid(),
|
||||||
|
sub: user.subject,
|
||||||
|
caps: userCount === 0 ? Roles.owner : Roles.member,
|
||||||
|
})
|
||||||
|
.onConflictDoNothing();
|
||||||
|
|
||||||
// TODO: This is breaking, to stop the "over-generation" of API
|
|
||||||
// keys because they are currently non-deletable in the headscale
|
|
||||||
// database. Look at this in the future once we have a solution
|
|
||||||
// or we have permissioned API keys.
|
|
||||||
userSession.set('user', user);
|
|
||||||
userSession.set('api_key', context.config.oidc?.headscale_api_key!);
|
|
||||||
userSession.set('state', 'auth');
|
|
||||||
return redirect('/machines', {
|
return redirect('/machines', {
|
||||||
headers: {
|
headers: {
|
||||||
'Set-Cookie': await context.sessions.commit(userSession),
|
'Set-Cookie': await context.sessions.createSession({
|
||||||
|
// 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.
|
||||||
|
api_key: context.config.oidc?.headscale_api_key!,
|
||||||
|
user,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -67,3 +106,26 @@ export async function loader({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PictureSource = NonNullable<
|
||||||
|
HeadplaneConfig['oidc']
|
||||||
|
>['profile_picture_source'];
|
||||||
|
|
||||||
|
function setOidcPictureForSource(user: FlowUser, source: PictureSource) {
|
||||||
|
// Already set by default in the callback, so we can just return it
|
||||||
|
if (source === 'oidc') {
|
||||||
|
return user.picture;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (source === 'gravatar') {
|
||||||
|
if (!user.email) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailHash = user.email.trim().toLowerCase();
|
||||||
|
const hash = createHash('sha256').update(emailHash).digest('hex');
|
||||||
|
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,42 +1,43 @@
|
|||||||
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
|
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
|
||||||
import type { LoadContext } from '~/server';
|
import type { LoadContext } from '~/server';
|
||||||
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
|
|
||||||
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
|
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
|
||||||
|
|
||||||
export async function loader({
|
export async function loader({
|
||||||
request,
|
request,
|
||||||
context,
|
context,
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
}: LoaderFunctionArgs<LoadContext>) {
|
||||||
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
|
try {
|
||||||
if ((session as Session<AuthSession>).has('api_key')) {
|
await context.sessions.auth(request);
|
||||||
return redirect('/machines');
|
return redirect('/machines');
|
||||||
}
|
} catch {}
|
||||||
|
|
||||||
if (!context.oidc) {
|
if (!context.oidc || !context.config.oidc) {
|
||||||
throw new Error('OIDC is not enabled');
|
throw new Error('OIDC is not enabled');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cookie = createCookie('__oidc_auth_flow', {
|
||||||
|
httpOnly: true,
|
||||||
|
maxAge: 300, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
const redirectUri =
|
const redirectUri =
|
||||||
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
|
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
|
||||||
const data = await beginAuthFlow(
|
const data = await beginAuthFlow(
|
||||||
context.oidc,
|
context.oidc,
|
||||||
redirectUri,
|
redirectUri,
|
||||||
// We can't get here without the OIDC config being defined
|
context.config.oidc.scope,
|
||||||
context.config.oidc!.token_endpoint_auth_method,
|
context.config.oidc.extra_params,
|
||||||
);
|
);
|
||||||
|
|
||||||
session.set('state', 'flow');
|
|
||||||
session.set('oidc', {
|
|
||||||
state: data.state,
|
|
||||||
nonce: data.nonce,
|
|
||||||
code_verifier: data.codeVerifier,
|
|
||||||
redirect_uri: redirectUri,
|
|
||||||
});
|
|
||||||
|
|
||||||
return redirect(data.url, {
|
return redirect(data.url, {
|
||||||
status: 302,
|
status: 302,
|
||||||
headers: {
|
headers: {
|
||||||
'Set-Cookie': await context.sessions.commit(session),
|
'Set-Cookie': await cookie.serialize({
|
||||||
|
state: data.state,
|
||||||
|
nonce: data.nonce,
|
||||||
|
code_verifier: data.codeVerifier,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -167,7 +167,7 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
|
|||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
isSortableDragging ? 'opacity-50' : '',
|
isSortableDragging ? 'opacity-50' : '',
|
||||||
isDragging ? 'ring bg-white dark:bg-headplane-900' : '',
|
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -179,7 +179,7 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
|
|||||||
<GripVertical
|
<GripVertical
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
className="p-0.5 focus:ring outline-none rounded-md"
|
className="p-0.5 focus:ring-3 outline-hidden rounded-md"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{domain}
|
{domain}
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { Form } from 'react-router';
|
import { Info } from 'lucide-react';
|
||||||
|
import { Form, useSubmit } from 'react-router';
|
||||||
import Button from '~/components/Button';
|
import Button from '~/components/Button';
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
|
import Switch from '~/components/Switch';
|
||||||
import TableList from '~/components/TableList';
|
import TableList from '~/components/TableList';
|
||||||
|
import Tooltip from '~/components/Tooltip';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
import AddNS from '../dialogs/add-ns';
|
import AddNS from '../dialogs/add-ns';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
nameservers: Record<string, string[]>;
|
nameservers: Record<string, string[]>;
|
||||||
|
overrideLocalDns: boolean;
|
||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ManageNS({ nameservers, isDisabled }: Props) {
|
export default function ManageNS({
|
||||||
|
nameservers,
|
||||||
|
isDisabled,
|
||||||
|
overrideLocalDns,
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||||
@@ -31,6 +39,7 @@ export default function ManageNS({ nameservers, isDisabled }: Props) {
|
|||||||
isGlobal={key === 'global'}
|
isGlobal={key === 'global'}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
nameservers={nameservers}
|
nameservers={nameservers}
|
||||||
|
overrideLocalDns={overrideLocalDns}
|
||||||
name={key}
|
name={key}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
@@ -45,6 +54,7 @@ interface ListProps {
|
|||||||
isGlobal: boolean;
|
isGlobal: boolean;
|
||||||
isDisabled: boolean;
|
isDisabled: boolean;
|
||||||
nameservers: Record<string, string[]>;
|
nameservers: Record<string, string[]>;
|
||||||
|
overrideLocalDns: boolean;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +62,7 @@ function NameserverList({
|
|||||||
isGlobal,
|
isGlobal,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
nameservers,
|
nameservers,
|
||||||
|
overrideLocalDns,
|
||||||
name,
|
name,
|
||||||
}: ListProps) {
|
}: ListProps) {
|
||||||
const list = isGlobal ? nameservers.global : nameservers[name];
|
const list = isGlobal ? nameservers.global : nameservers[name];
|
||||||
@@ -59,12 +70,54 @@ function NameserverList({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const submit = useSubmit();
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h2 className="text-md font-medium opacity-80">
|
{isGlobal ? (
|
||||||
{isGlobal ? 'Global Nameservers' : name}
|
<div className="flex items-center justify-between w-full">
|
||||||
</h2>
|
<h2 className="text-md font-medium opacity-80">
|
||||||
|
Global Nameservers
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Tooltip>
|
||||||
|
<Info className="size-4" />
|
||||||
|
<Tooltip.Body>
|
||||||
|
When enabled, use the DNS servers listed below to resolve
|
||||||
|
names outside the tailnet. When disabled (default), devices
|
||||||
|
will prefer their local DNS configuration.
|
||||||
|
<Link
|
||||||
|
to="https://tailscale.com/kb/1054/dns#global-nameservers"
|
||||||
|
name="Tailscale Global Nameservers Documentation"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</Link>
|
||||||
|
</Tooltip.Body>
|
||||||
|
</Tooltip>
|
||||||
|
<p>Override DNS servers</p>
|
||||||
|
<Switch
|
||||||
|
label="Override local DNS settings"
|
||||||
|
className="h-[15px] w-[23px] p-[2px]"
|
||||||
|
switchClassName="h-[9px] w-[9px]"
|
||||||
|
name="override_dns"
|
||||||
|
defaultSelected={overrideLocalDns}
|
||||||
|
onChange={(v) => {
|
||||||
|
submit(
|
||||||
|
{
|
||||||
|
action_id: 'override_dns',
|
||||||
|
override_dns: v ? 'true' : 'false',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<h2 className="text-md font-medium opacity-80">{name}</h2>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<TableList>
|
<TableList>
|
||||||
{list.length > 0
|
{list.length > 0
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { RepoForkedIcon } from '@primer/octicons-react';
|
import { Split } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import Chip from '~/components/Chip';
|
import Chip from '~/components/Chip';
|
||||||
import Dialog from '~/components/Dialog';
|
import Dialog from '~/components/Dialog';
|
||||||
@@ -35,15 +35,15 @@ export default function AddNameserver({ nameservers }: Props) {
|
|||||||
<Dialog.Button>Add nameserver</Dialog.Button>
|
<Dialog.Button>Add nameserver</Dialog.Button>
|
||||||
<Dialog.Panel>
|
<Dialog.Panel>
|
||||||
<Dialog.Title className="mb-4">Add nameserver</Dialog.Title>
|
<Dialog.Title className="mb-4">Add nameserver</Dialog.Title>
|
||||||
<input type="hidden" name="action_id" value="add_ns" />
|
<input name="action_id" type="hidden" value="add_ns" />
|
||||||
<Input
|
<Input
|
||||||
|
description="Use this IPv4 or IPv6 address to resolve names."
|
||||||
|
isInvalid={isInvalid}
|
||||||
isRequired
|
isRequired
|
||||||
label="Nameserver"
|
label="Nameserver"
|
||||||
description="Use this IPv4 or IPv6 address to resolve names."
|
|
||||||
placeholder="1.2.3.4"
|
|
||||||
name="ns"
|
name="ns"
|
||||||
onChange={setNs}
|
onChange={setNs}
|
||||||
isInvalid={isInvalid}
|
placeholder="1.2.3.4"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center justify-between mt-8">
|
<div className="flex items-center justify-between mt-8">
|
||||||
<div className="block">
|
<div className="block">
|
||||||
@@ -53,9 +53,9 @@ export default function AddNameserver({ nameservers }: Props) {
|
|||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<Chip
|
<Chip
|
||||||
text="Split DNS"
|
|
||||||
leftIcon={<RepoForkedIcon className="w-4 h-4 mr-0.5" />}
|
|
||||||
className={cn('inline-flex items-center')}
|
className={cn('inline-flex items-center')}
|
||||||
|
leftIcon={<Split className="w-3 h-3 mr-0.5" />}
|
||||||
|
text="Split DNS"
|
||||||
/>
|
/>
|
||||||
<Tooltip.Body>
|
<Tooltip.Body>
|
||||||
Only clients that support split DNS (Tailscale v1.8 or later
|
Only clients that support split DNS (Tailscale v1.8 or later
|
||||||
@@ -76,9 +76,9 @@ export default function AddNameserver({ nameservers }: Props) {
|
|||||||
<Input
|
<Input
|
||||||
isRequired={split === true}
|
isRequired={split === true}
|
||||||
label="Domain"
|
label="Domain"
|
||||||
placeholder="example.com"
|
|
||||||
name="split_name"
|
name="split_name"
|
||||||
onChange={setDomain}
|
onChange={setDomain}
|
||||||
|
placeholder="example.com"
|
||||||
/>
|
/>
|
||||||
<Dialog.Text className="text-sm">
|
<Dialog.Text className="text-sm">
|
||||||
Only single-label or fully-qualified queries matching this suffix
|
Only single-label or fully-qualified queries matching this suffix
|
||||||
@@ -86,7 +86,7 @@ export default function AddNameserver({ nameservers }: Props) {
|
|||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<input type="hidden" name="split_name" value="global" />
|
<input name="split_name" type="hidden" value="global" />
|
||||||
)}
|
)}
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ export async function dnsAction({
|
|||||||
return removeRecord(formData, context);
|
return removeRecord(formData, context);
|
||||||
case 'add_record':
|
case 'add_record':
|
||||||
return addRecord(formData, context);
|
return addRecord(formData, context);
|
||||||
|
case 'override_dns':
|
||||||
|
return overrideDns(formData, context);
|
||||||
default:
|
default:
|
||||||
return data({ success: false }, 400);
|
return data({ success: false }, 400);
|
||||||
}
|
}
|
||||||
@@ -104,7 +106,7 @@ async function removeNs(formData: FormData, context: LoadContext) {
|
|||||||
await context.hs.patch([
|
await context.hs.patch([
|
||||||
{
|
{
|
||||||
path: `dns.nameservers.split."${splitName}"`,
|
path: `dns.nameservers.split."${splitName}"`,
|
||||||
value: servers,
|
value: servers.length > 0 ? servers : null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -230,3 +232,20 @@ async function addRecord(formData: FormData, context: LoadContext) {
|
|||||||
|
|
||||||
await context.integration?.onConfigChange(context.client);
|
await context.integration?.onConfigChange(context.client);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function overrideDns(formData: FormData, context: LoadContext) {
|
||||||
|
const override = formData.get('override_dns')?.toString();
|
||||||
|
if (!override) {
|
||||||
|
return data({ success: false }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrideValue = override === 'true';
|
||||||
|
await context.hs.patch([
|
||||||
|
{
|
||||||
|
path: 'dns.override_local_dns',
|
||||||
|
value: overrideValue,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
await context.integration?.onConfigChange(context.client);
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ export async function loader({
|
|||||||
nameservers: config.dns.nameservers.global,
|
nameservers: config.dns.nameservers.global,
|
||||||
splitDns: config.dns.nameservers.split,
|
splitDns: config.dns.nameservers.split,
|
||||||
searchDomains: config.dns.search_domains,
|
searchDomains: config.dns.search_domains,
|
||||||
|
overrideDns: config.dns.override_local_dns,
|
||||||
extraRecords: context.hs.d,
|
extraRecords: context.hs.d,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -70,7 +71,7 @@ export default function Page() {
|
|||||||
const isDisabled = data.access === false || data.writable === false;
|
const isDisabled = data.access === false || data.writable === false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-16 max-w-screen-lg">
|
<div className="flex flex-col gap-16 max-w-(--breakpoint-lg)">
|
||||||
{data.writable ? undefined : (
|
{data.writable ? undefined : (
|
||||||
<Notice>
|
<Notice>
|
||||||
The Headscale configuration is read-only. You cannot make changes to
|
The Headscale configuration is read-only. You cannot make changes to
|
||||||
@@ -84,7 +85,11 @@ export default function Page() {
|
|||||||
</Notice>
|
</Notice>
|
||||||
)}
|
)}
|
||||||
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
|
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
|
||||||
<ManageNS nameservers={allNs} isDisabled={isDisabled} />
|
<ManageNS
|
||||||
|
nameservers={allNs}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
overrideLocalDns={data.overrideDns}
|
||||||
|
/>
|
||||||
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
|
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
|
||||||
<ManageDomains
|
<ManageDomains
|
||||||
searchDomains={data.searchDomains}
|
searchDomains={data.searchDomains}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react';
|
import { ChevronDown, Copy } from 'lucide-react';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { Link } from 'react-router';
|
import { Link } from 'react-router';
|
||||||
import Chip from '~/components/Chip';
|
import Chip from '~/components/Chip';
|
||||||
import Menu from '~/components/Menu';
|
import Menu from '~/components/Menu';
|
||||||
import StatusCircle from '~/components/StatusCircle';
|
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 { ExitNodeTag } from '~/components/tags/ExitNode';
|
||||||
import { ExpiryTag } from '~/components/tags/Expiry';
|
import { ExpiryTag } from '~/components/tags/Expiry';
|
||||||
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
|
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
|
||||||
import { SubnetTag } from '~/components/tags/Subnet';
|
import { SubnetTag } from '~/components/tags/Subnet';
|
||||||
|
import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH';
|
||||||
|
import type { User } from '~/types';
|
||||||
|
import cn from '~/utils/cn';
|
||||||
|
import * as hinfo from '~/utils/host-info';
|
||||||
import { PopulatedNode } from '~/utils/node-info';
|
import { PopulatedNode } from '~/utils/node-info';
|
||||||
import toast from '~/utils/toast';
|
import toast from '~/utils/toast';
|
||||||
import MenuOptions from './menu';
|
import MenuOptions from './menu';
|
||||||
@@ -46,24 +46,26 @@ export default function MachineRow({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={node.id}
|
|
||||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
||||||
|
key={node.id}
|
||||||
>
|
>
|
||||||
<td className="pl-0.5 py-2 focus-within:ring">
|
<td className="pl-0.5 py-2 focus-within:ring-3">
|
||||||
<Link
|
<Link
|
||||||
|
className={cn('group/link h-full focus:outline-hidden')}
|
||||||
to={`/machines/${node.id}`}
|
to={`/machines/${node.id}`}
|
||||||
className={cn('group/link h-full focus:outline-none')}
|
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className={cn(
|
className={cn(
|
||||||
'font-semibold leading-snug',
|
'font-semibold leading-snug',
|
||||||
'group-hover/link:text-blue-600',
|
'group-hover/link:text-blue-600',
|
||||||
'group-hover/link:dark:text-blue-400',
|
'dark:group-hover/link:text-blue-400',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{node.givenName}
|
{node.givenName}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm opacity-50">{node.user.name}</p>
|
<p className="text-sm opacity-50">
|
||||||
|
{node.user.name || node.user.displayName || node.user.email || node.user.id}
|
||||||
|
</p>
|
||||||
<div className="flex gap-1 flex-wrap mt-1.5">
|
<div className="flex gap-1 flex-wrap mt-1.5">
|
||||||
{mapTagsToComponents(node, uiTags)}
|
{mapTagsToComponents(node, uiTags)}
|
||||||
{node.validTags.map((tag) => (
|
{node.validTags.map((tag) => (
|
||||||
@@ -77,7 +79,7 @@ export default function MachineRow({
|
|||||||
{node.ipAddresses[0]}
|
{node.ipAddresses[0]}
|
||||||
<Menu placement="bottom end">
|
<Menu placement="bottom end">
|
||||||
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
||||||
<ChevronDownIcon className="w-4 h-4" />
|
<ChevronDown className="w-4 h-4" />
|
||||||
</Menu.IconButton>
|
</Menu.IconButton>
|
||||||
<Menu.Panel
|
<Menu.Panel
|
||||||
onAction={async (key) => {
|
onAction={async (key) => {
|
||||||
@@ -95,7 +97,7 @@ export default function MachineRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{ip}
|
{ip}
|
||||||
<CopyIcon className="w-3 h-3" />
|
<Copy className="w-3 h-3" />
|
||||||
</div>
|
</div>
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
))}
|
))}
|
||||||
@@ -129,8 +131,8 @@ export default function MachineRow({
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<StatusCircle
|
<StatusCircle
|
||||||
isOnline={node.online && !node.expired}
|
|
||||||
className="w-4 h-4"
|
className="w-4 h-4"
|
||||||
|
isOnline={node.online && !node.expired}
|
||||||
/>
|
/>
|
||||||
<p suppressHydrationWarning>
|
<p suppressHydrationWarning>
|
||||||
{node.online && !node.expired
|
{node.online && !node.expired
|
||||||
@@ -141,10 +143,10 @@ export default function MachineRow({
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-2 pr-0.5">
|
<td className="py-2 pr-0.5">
|
||||||
<MenuOptions
|
<MenuOptions
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
magic={magic}
|
||||||
node={node}
|
node={node}
|
||||||
users={users}
|
users={users}
|
||||||
magic={magic}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -175,6 +177,10 @@ export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
|
|||||||
uiTags.push('subnet-approved');
|
uiTags.push('subnet-approved');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
|
||||||
|
uiTags.push('tailscale-ssh');
|
||||||
|
}
|
||||||
|
|
||||||
if (isAgent === true) {
|
if (isAgent === true) {
|
||||||
uiTags.push('headplane-agent');
|
uiTags.push('headplane-agent');
|
||||||
}
|
}
|
||||||
@@ -187,27 +193,30 @@ export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
|
|||||||
switch (tag) {
|
switch (tag) {
|
||||||
case 'exit-approved':
|
case 'exit-approved':
|
||||||
case 'exit-waiting':
|
case 'exit-waiting':
|
||||||
return <ExitNodeTag key={tag} isEnabled={tag === 'exit-approved'} />;
|
return <ExitNodeTag isEnabled={tag === 'exit-approved'} key={tag} />;
|
||||||
|
|
||||||
case 'subnet-approved':
|
case 'subnet-approved':
|
||||||
case 'subnet-waiting':
|
case 'subnet-waiting':
|
||||||
return <SubnetTag key={tag} isEnabled={tag === 'subnet-approved'} />;
|
return <SubnetTag isEnabled={tag === 'subnet-approved'} key={tag} />;
|
||||||
|
|
||||||
case 'expired':
|
case 'expired':
|
||||||
case 'no-expiry':
|
case 'no-expiry':
|
||||||
return (
|
return (
|
||||||
<ExpiryTag
|
<ExpiryTag
|
||||||
|
expiry={node.expiry ?? undefined}
|
||||||
key={tag}
|
key={tag}
|
||||||
variant={tag}
|
variant={tag}
|
||||||
expiry={node.expiry ?? undefined}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case 'tailscale-ssh':
|
||||||
|
return <TailscaleSSHTag key={tag} />;
|
||||||
|
|
||||||
case 'headplane-agent':
|
case 'headplane-agent':
|
||||||
return <HeadplaneAgentTag />;
|
return <HeadplaneAgentTag key={tag} />;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Cog, Ellipsis } from 'lucide-react';
|
import { Cog, Ellipsis, SquareTerminal } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import Button from '~/components/Button';
|
||||||
import Menu from '~/components/Menu';
|
import Menu from '~/components/Menu';
|
||||||
import type { User } from '~/types';
|
import type { User } from '~/types';
|
||||||
import cn from '~/utils/cn';
|
import cn from '~/utils/cn';
|
||||||
@@ -10,7 +11,6 @@ import Move from '../dialogs/move';
|
|||||||
import Rename from '../dialogs/rename';
|
import Rename from '../dialogs/rename';
|
||||||
import Routes from '../dialogs/routes';
|
import Routes from '../dialogs/routes';
|
||||||
import Tags from '../dialogs/tags';
|
import Tags from '../dialogs/tags';
|
||||||
|
|
||||||
interface MenuProps {
|
interface MenuProps {
|
||||||
node: PopulatedNode;
|
node: PopulatedNode;
|
||||||
users: User[];
|
users: User[];
|
||||||
@@ -29,8 +29,11 @@ export default function MachineMenu({
|
|||||||
isDisabled,
|
isDisabled,
|
||||||
}: MenuProps) {
|
}: MenuProps) {
|
||||||
const [modal, setModal] = useState<Modal>(null);
|
const [modal, setModal] = useState<Modal>(null);
|
||||||
|
const supportsTailscaleSSH =
|
||||||
|
node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex items-center justify-end px-4 gap-1.5">
|
||||||
{modal === 'remove' && (
|
{modal === 'remove' && (
|
||||||
<Delete
|
<Delete
|
||||||
machine={node}
|
machine={node}
|
||||||
@@ -88,6 +91,49 @@ export default function MachineMenu({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{supportsTailscaleSSH ? (
|
||||||
|
isFullButton ? (
|
||||||
|
<Button
|
||||||
|
className="flex items-center gap-x-2"
|
||||||
|
variant="heavy"
|
||||||
|
onPress={() => {
|
||||||
|
// We need to use JS to open the SSH URL
|
||||||
|
// in a new WINDOW since href can only
|
||||||
|
// do a new TAB.
|
||||||
|
window.open(
|
||||||
|
`${__PREFIX__}/ssh?hostname=${node.name}`,
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer,width=800,height=600',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SquareTerminal className="h-5" />
|
||||||
|
<p>SSH</p>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
onPress={() => {
|
||||||
|
// We need to use JS to open the SSH URL
|
||||||
|
// in a new WINDOW since href can only
|
||||||
|
// do a new TAB.
|
||||||
|
window.open(
|
||||||
|
`${__PREFIX__}/ssh?hostname=${node.name}`,
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer,width=800,height=600',
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'py-0.5 w-fit bg-transparent border-transparent',
|
||||||
|
'border group-hover:border-headplane-200',
|
||||||
|
'dark:group-hover:border-headplane-700',
|
||||||
|
'opacity-0 pointer-events-none group-hover:opacity-100',
|
||||||
|
'group-hover:pointer-events-auto',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
SSH
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
) : undefined}
|
||||||
<Menu isDisabled={isDisabled}>
|
<Menu isDisabled={isDisabled}>
|
||||||
{isFullButton ? (
|
{isFullButton ? (
|
||||||
<Menu.Button className="flex items-center gap-x-2">
|
<Menu.Button className="flex items-center gap-x-2">
|
||||||
@@ -126,6 +172,6 @@ export default function MachineMenu({
|
|||||||
</Menu.Section>
|
</Menu.Section>
|
||||||
</Menu.Panel>
|
</Menu.Panel>
|
||||||
</Menu>
|
</Menu>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
|
|||||||
This will disconnect the machine from your Tailnet. In order to
|
This will disconnect the machine from your Tailnet. In order to
|
||||||
reconnect, you will need to re-authenticate from the device.
|
reconnect, you will need to re-authenticate from the device.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<input type="hidden" name="action_id" value="expire" />
|
<input name="action_id" type="hidden" value="expire" />
|
||||||
<input type="hidden" name="node_id" value={machine.id} />
|
<input name="node_id" type="hidden" value={machine.id} />
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{users.map((user) => (
|
{users.map((user) => (
|
||||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export default function NewMachine(data: NewMachineProps) {
|
|||||||
placeholder="Select a user"
|
placeholder="Select a user"
|
||||||
>
|
>
|
||||||
{data.users.map((user) => (
|
{data.users.map((user) => (
|
||||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
|
|||||||
@@ -59,10 +59,10 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
|||||||
)}
|
)}
|
||||||
<TableList.Item
|
<TableList.Item
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-b-xl focus-within:ring',
|
'rounded-b-xl focus-within:ring-3',
|
||||||
tag.length > 0 &&
|
tag.length > 0 &&
|
||||||
(!tag.startsWith('tag:') || tags.includes(tag)) &&
|
(!tag.startsWith('tag:') || tags.includes(tag)) &&
|
||||||
'ring ring-red-500 ring-opacity-50',
|
'ring-3 ring-red-500 ring-opacity-50',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export async function machineAction({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const apiKey = session.get('api_key')!;
|
const apiKey = session.api_key;
|
||||||
|
|
||||||
const action = formData.get('action_id')?.toString();
|
const action = formData.get('action_id')?.toString();
|
||||||
if (!action) {
|
if (!action) {
|
||||||
@@ -55,7 +55,7 @@ export async function machineAction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
node.user.providerId?.split('/').pop() !== session.get('user')!.subject &&
|
node.user.providerId?.split('/').pop() !== session.user.subject &&
|
||||||
!check
|
!check
|
||||||
) {
|
) {
|
||||||
throw data('You do not have permission to act on this machine', {
|
throw data('You do not have permission to act on this machine', {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||||
import { Link as RemixLink, useLoaderData } from 'react-router';
|
import { Link as RemixLink, useLoaderData } from 'react-router';
|
||||||
import { mapTag } from 'yaml/util';
|
|
||||||
import Attribute from '~/components/Attribute';
|
import Attribute from '~/components/Attribute';
|
||||||
import Button from '~/components/Button';
|
import Button from '~/components/Button';
|
||||||
import Card from '~/components/Card';
|
import Card from '~/components/Card';
|
||||||
@@ -40,9 +39,9 @@ export async function loader({
|
|||||||
const [machine, { users }] = await Promise.all([
|
const [machine, { users }] = await Promise.all([
|
||||||
context.client.get<{ node: Machine }>(
|
context.client.get<{ node: Machine }>(
|
||||||
`v1/node/${params.id}`,
|
`v1/node/${params.id}`,
|
||||||
session.get('api_key')!,
|
session.api_key,
|
||||||
),
|
),
|
||||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
context.client.get<{ users: User[] }>('v1/user', session.api_key),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const lookup = await context.agents?.lookup([machine.node.nodeKey]);
|
const lookup = await context.agents?.lookup([machine.node.nodeKey]);
|
||||||
@@ -78,7 +77,7 @@ export default function Page() {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-8 text-md">
|
<p className="mb-8 text-md">
|
||||||
<RemixLink to="/machines" className="font-medium">
|
<RemixLink className="font-medium" to="/machines">
|
||||||
All Machines
|
All Machines
|
||||||
</RemixLink>
|
</RemixLink>
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
@@ -92,9 +91,9 @@ export default function Page() {
|
|||||||
>
|
>
|
||||||
<span className="flex items-baseline gap-x-4 text-sm">
|
<span className="flex items-baseline gap-x-4 text-sm">
|
||||||
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
||||||
<StatusCircle isOnline={node.online} className="w-4 h-4" />
|
<StatusCircle className="w-4 h-4" isOnline={node.online} />
|
||||||
</span>
|
</span>
|
||||||
<MenuOptions isFullButton node={node} users={users} magic={magic} />
|
<MenuOptions isFullButton magic={magic} node={node} users={users} />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-1 mb-4">
|
<div className="flex gap-1 mb-4">
|
||||||
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
||||||
@@ -109,7 +108,10 @@ export default function Page() {
|
|||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-x-2.5 mt-1">
|
<div className="flex items-center gap-x-2.5 mt-1">
|
||||||
<UserCircle />
|
<UserCircle />
|
||||||
{node.user.name}
|
{node.user.name ||
|
||||||
|
node.user.displayName ||
|
||||||
|
node.user.email ||
|
||||||
|
node.user.id}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 pl-4">
|
<div className="p-2 pl-4">
|
||||||
@@ -124,14 +126,14 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Routes node={node} isOpen={showRouting} setIsOpen={setShowRouting} />
|
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
|
||||||
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
|
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<p>
|
<p>
|
||||||
Subnets let you expose physical network routes onto Tailscale.{' '}
|
Subnets let you expose physical network routes onto Tailscale.{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://tailscale.com/kb/1019/subnets"
|
|
||||||
name="Tailscale Subnets Documentation"
|
name="Tailscale Subnets Documentation"
|
||||||
|
to="https://tailscale.com/kb/1019/subnets"
|
||||||
>
|
>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
@@ -139,11 +141,11 @@ export default function Page() {
|
|||||||
<Button onPress={() => setShowRouting(true)}>Review</Button>
|
<Button onPress={() => setShowRouting(true)}>Review</Button>
|
||||||
</div>
|
</div>
|
||||||
<Card
|
<Card
|
||||||
variant="flat"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full max-w-full grid sm:grid-cols-2',
|
'w-full max-w-full grid sm:grid-cols-2',
|
||||||
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
|
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
|
||||||
)}
|
)}
|
||||||
|
variant="flat"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||||
@@ -167,11 +169,11 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => setShowRouting(true)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||||
'text-blue-500 dark:text-blue-400',
|
'text-blue-500 dark:text-blue-400',
|
||||||
)}
|
)}
|
||||||
|
onPress={() => setShowRouting(true)}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -199,11 +201,11 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => setShowRouting(true)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||||
'text-blue-500 dark:text-blue-400',
|
'text-blue-500 dark:text-blue-400',
|
||||||
)}
|
)}
|
||||||
|
onPress={() => setShowRouting(true)}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -234,11 +236,11 @@ export default function Page() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onPress={() => setShowRouting(true)}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||||
'text-blue-500 dark:text-blue-400',
|
'text-blue-500 dark:text-blue-400',
|
||||||
)}
|
)}
|
||||||
|
onPress={() => setShowRouting(true)}
|
||||||
>
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
@@ -250,15 +252,23 @@ export default function Page() {
|
|||||||
issues.
|
issues.
|
||||||
</p>
|
</p>
|
||||||
<Card
|
<Card
|
||||||
variant="flat"
|
|
||||||
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12"
|
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12"
|
||||||
|
variant="flat"
|
||||||
>
|
>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<Attribute name="Creator" value={node.user.name} />
|
<Attribute
|
||||||
|
name="Creator"
|
||||||
|
value={
|
||||||
|
node.user.name ||
|
||||||
|
node.user.displayName ||
|
||||||
|
node.user.email ||
|
||||||
|
node.user.id
|
||||||
|
}
|
||||||
|
/>
|
||||||
<Attribute name="Machine name" value={node.givenName} />
|
<Attribute name="Machine name" value={node.givenName} />
|
||||||
<Attribute
|
<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"
|
name="OS hostname"
|
||||||
|
tooltip="OS hostname is published by the machine’s operating system and is used as the default name for the machine."
|
||||||
value={node.name}
|
value={node.name}
|
||||||
/>
|
/>
|
||||||
{stats ? (
|
{stats ? (
|
||||||
@@ -268,14 +278,14 @@ export default function Page() {
|
|||||||
</>
|
</>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<Attribute
|
<Attribute
|
||||||
tooltip="ID for this machine. Used in the Headscale API."
|
|
||||||
name="ID"
|
name="ID"
|
||||||
|
tooltip="ID for this machine. Used in the Headscale API."
|
||||||
value={node.id}
|
value={node.id}
|
||||||
/>
|
/>
|
||||||
<Attribute
|
<Attribute
|
||||||
isCopyable
|
isCopyable
|
||||||
tooltip="Public key which uniquely identifies this machine."
|
|
||||||
name="Node key"
|
name="Node key"
|
||||||
|
tooltip="Public key which uniquely identifies this machine."
|
||||||
value={node.nodeKey}
|
value={node.nodeKey}
|
||||||
/>
|
/>
|
||||||
<Attribute
|
<Attribute
|
||||||
@@ -312,27 +322,27 @@ export default function Page() {
|
|||||||
</p>
|
</p>
|
||||||
<Attribute
|
<Attribute
|
||||||
isCopyable
|
isCopyable
|
||||||
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
|
||||||
name="Tailscale IPv4"
|
name="Tailscale IPv4"
|
||||||
|
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
||||||
value={getIpv4Address(node.ipAddresses)}
|
value={getIpv4Address(node.ipAddresses)}
|
||||||
/>
|
/>
|
||||||
<Attribute
|
<Attribute
|
||||||
isCopyable
|
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"
|
name="Tailscale IPv6"
|
||||||
|
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."
|
||||||
value={getIpv6Address(node.ipAddresses)}
|
value={getIpv6Address(node.ipAddresses)}
|
||||||
/>
|
/>
|
||||||
<Attribute
|
<Attribute
|
||||||
isCopyable
|
isCopyable
|
||||||
tooltip="Users of your tailnet can use this DNS short name to access this machine."
|
|
||||||
name="Short domain"
|
name="Short domain"
|
||||||
|
tooltip="Users of your tailnet can use this DNS short name to access this machine."
|
||||||
value={node.givenName}
|
value={node.givenName}
|
||||||
/>
|
/>
|
||||||
{magic ? (
|
{magic ? (
|
||||||
<Attribute
|
<Attribute
|
||||||
isCopyable
|
isCopyable
|
||||||
tooltip="Users of your tailnet can use this DNS name to access this machine."
|
|
||||||
name="Full domain"
|
name="Full domain"
|
||||||
|
tooltip="Users of your tailnet can use this DNS name to access this machine."
|
||||||
value={`${node.givenName}.${magic}`}
|
value={`${node.givenName}.${magic}`}
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
@@ -342,13 +352,13 @@ export default function Page() {
|
|||||||
Client Connectivity
|
Client Connectivity
|
||||||
</p>
|
</p>
|
||||||
<Attribute
|
<Attribute
|
||||||
tooltip="Whether the machine is behind a difficult NAT that varies the machine’s IP address depending on the destination."
|
|
||||||
name="Varies"
|
name="Varies"
|
||||||
|
tooltip="Whether the machine is behind a difficult NAT that varies the machine’s IP address depending on the destination."
|
||||||
value={stats.NetInfo?.MappingVariesByDestIP ? 'Yes' : 'No'}
|
value={stats.NetInfo?.MappingVariesByDestIP ? 'Yes' : 'No'}
|
||||||
/>
|
/>
|
||||||
<Attribute
|
<Attribute
|
||||||
tooltip="Whether the machine needs to traverse NATs with hairpinning."
|
|
||||||
name="Hairpinning"
|
name="Hairpinning"
|
||||||
|
tooltip="Whether the machine needs to traverse NATs with hairpinning."
|
||||||
value={stats.NetInfo?.HairPinning ? 'Yes' : 'No'}
|
value={stats.NetInfo?.HairPinning ? 'Yes' : 'No'}
|
||||||
/>
|
/>
|
||||||
<Attribute
|
<Attribute
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { InfoIcon } from '@primer/octicons-react';
|
import { Info } from 'lucide-react';
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||||
import { useLoaderData } from 'react-router';
|
import { useLoaderData } from 'react-router';
|
||||||
import Code from '~/components/Code';
|
import Code from '~/components/Code';
|
||||||
@@ -18,7 +18,7 @@ export async function loader({
|
|||||||
context,
|
context,
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
}: LoaderFunctionArgs<LoadContext>) {
|
||||||
const session = await context.sessions.auth(request);
|
const session = await context.sessions.auth(request);
|
||||||
const user = session.get('user');
|
const user = session.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
throw new Error('Missing user session. Please log in again.');
|
throw new Error('Missing user session. Please log in again.');
|
||||||
}
|
}
|
||||||
@@ -41,11 +41,8 @@ export async function loader({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [{ nodes }, { users }] = await Promise.all([
|
const [{ nodes }, { users }] = await Promise.all([
|
||||||
context.client.get<{ nodes: Machine[] }>(
|
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
|
||||||
'v1/node',
|
context.client.get<{ users: User[] }>('v1/user', session.api_key),
|
||||||
session.get('api_key')!,
|
|
||||||
),
|
|
||||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let magic: string | undefined;
|
let magic: string | undefined;
|
||||||
@@ -90,18 +87,18 @@ export default function Page() {
|
|||||||
<p>
|
<p>
|
||||||
Manage the devices connected to your Tailnet.{' '}
|
Manage the devices connected to your Tailnet.{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://tailscale.com/kb/1372/manage-devices"
|
|
||||||
name="Tailscale Manage Devices Documentation"
|
name="Tailscale Manage Devices Documentation"
|
||||||
|
to="https://tailscale.com/kb/1372/manage-devices"
|
||||||
>
|
>
|
||||||
Learn more
|
Learn more
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<NewMachine
|
<NewMachine
|
||||||
|
disabledKeys={data.preAuth ? [] : ['pre-auth']}
|
||||||
|
isDisabled={!data.writable}
|
||||||
server={data.publicServer ?? data.server}
|
server={data.publicServer ?? data.server}
|
||||||
users={data.users}
|
users={data.users}
|
||||||
isDisabled={!data.writable}
|
|
||||||
disabledKeys={data.preAuth ? [] : ['pre-auth']}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<table className="table-auto w-full rounded-lg">
|
<table className="table-auto w-full rounded-lg">
|
||||||
@@ -113,7 +110,7 @@ export default function Page() {
|
|||||||
<p className="uppercase text-xs font-bold">Addresses</p>
|
<p className="uppercase text-xs font-bold">Addresses</p>
|
||||||
{data.magic ? (
|
{data.magic ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<InfoIcon className="w-4 h-4" />
|
<Info className="w-4 h-4" />
|
||||||
<Tooltip.Body className="font-normal">
|
<Tooltip.Body className="font-normal">
|
||||||
Since MagicDNS is enabled, you can access devices based on
|
Since MagicDNS is enabled, you can access devices based on
|
||||||
their name and also at{' '}
|
their name and also at{' '}
|
||||||
@@ -141,16 +138,16 @@ export default function Page() {
|
|||||||
>
|
>
|
||||||
{data.populatedNodes.map((machine) => (
|
{data.populatedNodes.map((machine) => (
|
||||||
<MachineRow
|
<MachineRow
|
||||||
key={machine.id}
|
|
||||||
node={machine}
|
|
||||||
users={data.users}
|
|
||||||
magic={data.magic}
|
|
||||||
isAgent={data.agent ? data.agent === machine.nodeKey : undefined}
|
isAgent={data.agent ? data.agent === machine.nodeKey : undefined}
|
||||||
isDisabled={
|
isDisabled={
|
||||||
data.writable
|
data.writable
|
||||||
? false // If the user has write permissions, they can edit all machines
|
? false // If the user has write permissions, they can edit all machines
|
||||||
: machine.user.providerId?.split('/').pop() !== data.subject
|
: machine.user.providerId?.split('/').pop() !== data.subject
|
||||||
}
|
}
|
||||||
|
key={machine.id}
|
||||||
|
magic={data.magic}
|
||||||
|
node={machine}
|
||||||
|
users={data.users}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export async function authKeysAction({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const apiKey = session.get('api_key')!;
|
const apiKey = session.api_key;
|
||||||
const action = formData.get('action_id')?.toString();
|
const action = formData.get('action_id')?.toString();
|
||||||
if (!action) {
|
if (!action) {
|
||||||
throw data('Missing `action_id` in the form data.', {
|
throw data('Missing `action_id` in the form data.', {
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ export default function AuthKeyRow({ authKey, user, url }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Attribute name="Key" value={authKey.key} isCopyable />
|
<Attribute isCopyable name="Key" value={authKey.key} />
|
||||||
<Attribute name="User" value={user.name} isCopyable />
|
<Attribute
|
||||||
|
isCopyable
|
||||||
|
name="User"
|
||||||
|
value={user.name || user.displayName || user.email || user.id}
|
||||||
|
/>
|
||||||
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
|
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
|
||||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
|
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
|
||||||
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
|
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
|
||||||
@@ -30,13 +34,12 @@ export default function AuthKeyRow({ authKey, user, url }: Props) {
|
|||||||
<Code className="text-sm">
|
<Code className="text-sm">
|
||||||
tailscale up --login-server={url} --authkey {authKey.key}
|
tailscale up --login-server={url} --authkey {authKey.key}
|
||||||
</Code>
|
</Code>
|
||||||
<div suppressHydrationWarning className="flex gap-4 items-center">
|
<div className="flex gap-4 items-center" suppressHydrationWarning>
|
||||||
{(authKey.used && !authKey.reusable) ||
|
{(authKey.used && !authKey.reusable) ||
|
||||||
new Date(authKey.expiration) < new Date() ? undefined : (
|
new Date(authKey.expiration) < new Date() ? undefined : (
|
||||||
<ExpireAuthKey authKey={authKey} user={user} />
|
<ExpireAuthKey authKey={authKey} user={user} />
|
||||||
)}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
|
||||||
className="my-4"
|
className="my-4"
|
||||||
onPress={async () => {
|
onPress={async () => {
|
||||||
await navigator.clipboard.writeText(
|
await navigator.clipboard.writeText(
|
||||||
@@ -45,6 +48,7 @@ export default function AuthKeyRow({ authKey, user, url }: Props) {
|
|||||||
|
|
||||||
toast('Copied command to clipboard');
|
toast('Copied command to clipboard');
|
||||||
}}
|
}}
|
||||||
|
variant="light"
|
||||||
>
|
>
|
||||||
Copy Tailscale Command
|
Copy Tailscale Command
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{data.users.map((user) => (
|
{data.users.map((user) => (
|
||||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { FileKey2 } from 'lucide-react';
|
import { FileKey2 } from 'lucide-react';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||||
import { useLoaderData } from 'react-router';
|
import { Link as RemixLink, useLoaderData } from 'react-router';
|
||||||
import { Link as RemixLink } from 'react-router';
|
|
||||||
import Code from '~/components/Code';
|
import Code from '~/components/Code';
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
import Notice from '~/components/Notice';
|
import Notice from '~/components/Notice';
|
||||||
@@ -23,7 +22,7 @@ export async function loader({
|
|||||||
const session = await context.sessions.auth(request);
|
const session = await context.sessions.auth(request);
|
||||||
const { users } = await context.client.get<{ users: User[] }>(
|
const { users } = await context.client.get<{ users: User[] }>(
|
||||||
'v1/user',
|
'v1/user',
|
||||||
session.get('api_key')!,
|
session.api_key,
|
||||||
);
|
);
|
||||||
|
|
||||||
const preAuthKeys = await Promise.all(
|
const preAuthKeys = await Promise.all(
|
||||||
@@ -36,7 +35,7 @@ export async function loader({
|
|||||||
try {
|
try {
|
||||||
const { preAuthKeys } = await context.client.get<{
|
const { preAuthKeys } = await context.client.get<{
|
||||||
preAuthKeys: PreAuthKey[];
|
preAuthKeys: PreAuthKey[];
|
||||||
}>(`v1/preauthkey?${qp.toString()}`, session.get('api_key')!);
|
}>(`v1/preauthkey?${qp.toString()}`, session.api_key);
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
user,
|
user,
|
||||||
@@ -139,13 +138,15 @@ export default function Page() {
|
|||||||
|
|
||||||
return key.reusable;
|
return key.reusable;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
});
|
});
|
||||||
}, [keys, selectedUser, status]);
|
}, [keys, selectedUser, status]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col md:w-2/3">
|
<div className="flex flex-col md:w-2/3">
|
||||||
<p className="mb-8 text-md">
|
<p className="mb-8 text-md">
|
||||||
<RemixLink to="/settings" className="font-medium">
|
<RemixLink className="font-medium" to="/settings">
|
||||||
Settings
|
Settings
|
||||||
</RemixLink>
|
</RemixLink>
|
||||||
<span className="mx-2">/</span> Pre-Auth Keys
|
<span className="mx-2">/</span> Pre-Auth Keys
|
||||||
@@ -176,8 +177,8 @@ export default function Page() {
|
|||||||
devices to your Tailnet. To learn more about using pre-authentication
|
devices to your Tailnet. To learn more about using pre-authentication
|
||||||
keys, visit the{' '}
|
keys, visit the{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
|
||||||
name="Tailscale Auth Keys documentation"
|
name="Tailscale Auth Keys documentation"
|
||||||
|
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||||
>
|
>
|
||||||
Tailscale documentation
|
Tailscale documentation
|
||||||
</Link>
|
</Link>
|
||||||
@@ -185,31 +186,31 @@ export default function Page() {
|
|||||||
<AddAuthKey users={users} />
|
<AddAuthKey users={users} />
|
||||||
<div className="flex items-center gap-4 mt-4">
|
<div className="flex items-center gap-4 mt-4">
|
||||||
<Select
|
<Select
|
||||||
label="User"
|
|
||||||
placeholder="Select a user"
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
defaultSelectedKey="__headplane_all"
|
defaultSelectedKey="__headplane_all"
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
|
label="User"
|
||||||
onSelectionChange={(value) =>
|
onSelectionChange={(value) =>
|
||||||
setSelectedUser(value?.toString() ?? '')
|
setSelectedUser(value?.toString() ?? '')
|
||||||
}
|
}
|
||||||
|
placeholder="Select a user"
|
||||||
>
|
>
|
||||||
{[
|
{[
|
||||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||||
...keys.map(({ user }) => (
|
...keys.map(({ user }) => (
|
||||||
<Select.Item key={user.id}>{user.name}</Select.Item>
|
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
||||||
)),
|
)),
|
||||||
]}
|
]}
|
||||||
</Select>
|
</Select>
|
||||||
<Select
|
<Select
|
||||||
label="Status"
|
|
||||||
placeholder="Select a status"
|
|
||||||
className="w-full"
|
className="w-full"
|
||||||
defaultSelectedKey="active"
|
defaultSelectedKey="active"
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
|
label="Status"
|
||||||
onSelectionChange={(value) =>
|
onSelectionChange={(value) =>
|
||||||
setStatus((value?.toString() ?? 'active') as Status)
|
setStatus((value?.toString() ?? 'active') as Status)
|
||||||
}
|
}
|
||||||
|
placeholder="Select a status"
|
||||||
>
|
>
|
||||||
<Select.Item key="all">All</Select.Item>
|
<Select.Item key="all">All</Select.Item>
|
||||||
<Select.Item key="active">Active</Select.Item>
|
<Select.Item key="active">Active</Select.Item>
|
||||||
|
|||||||
@@ -1,33 +0,0 @@
|
|||||||
import { ArrowRightIcon } from '@primer/octicons-react';
|
|
||||||
import { Link as RemixLink } from 'react-router';
|
|
||||||
import Button from '~/components/Button';
|
|
||||||
import Link from '~/components/Link';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
|
|
||||||
export default function AgentSection() {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="flex flex-col w-2/3">
|
|
||||||
<h1 className="text-2xl font-medium mb-4">Local Agent</h1>
|
|
||||||
<p>
|
|
||||||
Headplane provides a local agent that can be installed on a server to
|
|
||||||
provide additional features including viewing device information and
|
|
||||||
SSH access via the web interface (soon). To learn more about the agent
|
|
||||||
visit the{' '}
|
|
||||||
<Link
|
|
||||||
to="https://github.com/tale/headplane/blob/main/docs/Headplane-Agent.md"
|
|
||||||
name="Headplane Agent Documentation"
|
|
||||||
>
|
|
||||||
Headplane documentation
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<RemixLink to="/settings/local-agent">
|
|
||||||
<div className={cn('text-lg font-medium flex items-center')}>
|
|
||||||
Manage Agent
|
|
||||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
|
||||||
</div>
|
|
||||||
</RemixLink>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
import Card from '~/components/Card';
|
|
||||||
import StatusCircle from '~/components/StatusCircle';
|
|
||||||
import type { HostInfo } from '~/types';
|
|
||||||
import * as hinfo from '~/utils/host-info';
|
|
||||||
|
|
||||||
export type Props = {
|
|
||||||
reachable: boolean;
|
|
||||||
hostInfo: HostInfo;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function AgentManagement({ reachable, hostInfo }: Props) {
|
|
||||||
console.log('hostInfo:', hostInfo);
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col w-2/3">
|
|
||||||
<h1 className="text-2xl font-medium mb-4">Local Agent Configuration</h1>
|
|
||||||
<p className="mb-8">
|
|
||||||
A local agent has already been configured for this Headplane instance.
|
|
||||||
You can manage the agent settings here.
|
|
||||||
</p>
|
|
||||||
<Card>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<StatusCircle isOnline={reachable} className="w-4 h-4 px-1" />
|
|
||||||
<div>
|
|
||||||
<p className="text-lg font-bold">
|
|
||||||
{hostInfo.Hostname ?? 'Unknown'}
|
|
||||||
</p>
|
|
||||||
<p className="leading-snug">
|
|
||||||
{hinfo.getTSVersion(hostInfo)}
|
|
||||||
<span className="ml-2 text-sm text-headplane-600 dark:text-headplane-300">
|
|
||||||
{hinfo.getOSInfo(hostInfo)}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{JSON.stringify(hostInfo)}
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { useMemo } from 'react';
|
|
||||||
import { type LoaderFunctionArgs, useLoaderData } from 'react-router';
|
|
||||||
import { commitSession, getSession } from '~/utils/sessions.server';
|
|
||||||
import { queryAgent } from '~/utils/ws-agent';
|
|
||||||
import AgentManagement from './components/agent/manage';
|
|
||||||
|
|
||||||
export async function loader({ request, context }: LoaderFunctionArgs) {
|
|
||||||
const { ws, wsAuthKey } = context;
|
|
||||||
const session = await getSession(request.headers.get('Cookie'));
|
|
||||||
const onboarding = session.get('agent_onboarding') ?? false;
|
|
||||||
|
|
||||||
const nodeKey =
|
|
||||||
'nodekey:542dad28354eb8d51e240aada7adf0222ba3ecc74af0bbd56123f03eefdb391b';
|
|
||||||
const stats = await queryAgent([nodeKey]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
configured: wsAuthKey !== undefined,
|
|
||||||
onboarding,
|
|
||||||
stats: stats?.[nodeKey],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const data = useLoaderData<typeof loader>();
|
|
||||||
|
|
||||||
// Whether we show the onboarding or management UI
|
|
||||||
const management = useMemo(() => {
|
|
||||||
return data.configured && data.onboarding === false;
|
|
||||||
}, [data.configured, data.onboarding]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-8 max-w-screen-lg">
|
|
||||||
{management ? (
|
|
||||||
<AgentManagement reachable={true} hostInfo={data.stats} />
|
|
||||||
) : (
|
|
||||||
<div>
|
|
||||||
<h1>Local Agent Coming Soon</h1>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ArrowRightIcon } from '@primer/octicons-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
LoaderFunctionArgs,
|
LoaderFunctionArgs,
|
||||||
Link as RemixLink,
|
Link as RemixLink,
|
||||||
@@ -18,7 +18,7 @@ export default function Page() {
|
|||||||
const { config, oidc } = useLoaderData<typeof loader>();
|
const { config, oidc } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-8 max-w-screen-lg">
|
<div className="flex flex-col gap-8 max-w-(--breakpoint-lg)">
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Settings</h1>
|
<h1 className="text-2xl font-medium mb-4">Settings</h1>
|
||||||
<p>
|
<p>
|
||||||
@@ -34,8 +34,8 @@ export default function Page() {
|
|||||||
add devices to your Tailnet. To learn more about using
|
add devices to your Tailnet. To learn more about using
|
||||||
pre-authentication keys, visit the{' '}
|
pre-authentication keys, visit the{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
|
||||||
name="Tailscale Auth Keys documentation"
|
name="Tailscale Auth Keys documentation"
|
||||||
|
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||||
>
|
>
|
||||||
Tailscale documentation
|
Tailscale documentation
|
||||||
</Link>
|
</Link>
|
||||||
@@ -44,7 +44,7 @@ export default function Page() {
|
|||||||
<RemixLink to="/settings/auth-keys">
|
<RemixLink to="/settings/auth-keys">
|
||||||
<div className="text-lg font-medium flex items-center">
|
<div className="text-lg font-medium flex items-center">
|
||||||
Manage Auth Keys
|
Manage Auth Keys
|
||||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
<ArrowRight className="w-5 h-5 ml-2" />
|
||||||
</div>
|
</div>
|
||||||
</RemixLink>
|
</RemixLink>
|
||||||
{config && oidc ? (
|
{config && oidc ? (
|
||||||
@@ -60,8 +60,8 @@ export default function Page() {
|
|||||||
groups and Headplane will also respect these settings when
|
groups and Headplane will also respect these settings when
|
||||||
authenticating.{' '}
|
authenticating.{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
|
||||||
name="Headscale OIDC documentation"
|
name="Headscale OIDC documentation"
|
||||||
|
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||||
>
|
>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
@@ -70,7 +70,7 @@ export default function Page() {
|
|||||||
<RemixLink to="/settings/restrictions">
|
<RemixLink to="/settings/restrictions">
|
||||||
<div className="text-lg font-medium flex items-center">
|
<div className="text-lg font-medium flex items-center">
|
||||||
Manage Restrictions
|
Manage Restrictions
|
||||||
<ArrowRightIcon className="w-5 h-5 ml-2" />
|
<ArrowRight className="w-5 h-5 ml-2" />
|
||||||
</div>
|
</div>
|
||||||
</RemixLink>
|
</RemixLink>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export default function Page() {
|
|||||||
const isDisabled = writable ? !access : true;
|
const isDisabled = writable ? !access : true;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 max-w-screen-lg">
|
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-2/3">
|
||||||
<p className="mb-4 text-md">
|
<p className="mb-4 text-md">
|
||||||
<RemixLink to="/settings" className="font-medium">
|
<RemixLink to="/settings" className="font-medium">
|
||||||
|
|||||||
@@ -0,0 +1,316 @@
|
|||||||
|
/** biome-ignore-all lint/correctness/noNestedComponentDefinitions: Wtf? */
|
||||||
|
|
||||||
|
import { faker } from '@faker-js/faker';
|
||||||
|
import { eq } from 'drizzle-orm';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ActionFunctionArgs,
|
||||||
|
data,
|
||||||
|
LinksFunction,
|
||||||
|
LoaderFunctionArgs,
|
||||||
|
ShouldRevalidateFunction,
|
||||||
|
useLoaderData,
|
||||||
|
useSubmit,
|
||||||
|
} from 'react-router';
|
||||||
|
import { ExternalScriptsHandle } from 'remix-utils/external-scripts';
|
||||||
|
import { LoadContext } from '~/server';
|
||||||
|
import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
|
||||||
|
import { Machine, PreAuthKey, User } from '~/types';
|
||||||
|
import { useLiveData } from '~/utils/live-data';
|
||||||
|
import UserPrompt from './user-prompt';
|
||||||
|
import XTerm from './xterm.client';
|
||||||
|
|
||||||
|
export const shouldRevalidate: ShouldRevalidateFunction = () => {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function loader({
|
||||||
|
request,
|
||||||
|
context,
|
||||||
|
}: LoaderFunctionArgs<LoadContext>) {
|
||||||
|
const origin = new URL(request.url).origin;
|
||||||
|
const assets = ['/wasm_exec.js', '/hp_ssh.wasm'];
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
|
for (const file of assets) {
|
||||||
|
const res = await fetch(`${origin}${file}`, { method: 'HEAD' });
|
||||||
|
if (!res.ok) missing.push(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0) {
|
||||||
|
throw data('WebSSH is not configured in this build.', 405);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!context.agents?.agentID()) {
|
||||||
|
throw data(
|
||||||
|
'WebSSH is only available with the Headplane agent integration',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await context.sessions.auth(request);
|
||||||
|
if (session.user.subject === 'unknown-non-oauth') {
|
||||||
|
throw data('Only OAuth users are allowed to use WebSSH', 403);
|
||||||
|
}
|
||||||
|
const { users } = await context.client.get<{ users: User[] }>(
|
||||||
|
'v1/user',
|
||||||
|
session.api_key,
|
||||||
|
);
|
||||||
|
|
||||||
|
// MARK: This assumes that a user has authenticated with Headscale first
|
||||||
|
// Since the only way to enforce permissions via ACLs is to generate a
|
||||||
|
// pre-authkey which REQUIRES a user ID, meaning the user has to have
|
||||||
|
// authenticated with Headscale first.
|
||||||
|
const lookup = users.find((u) => {
|
||||||
|
const subject = u.providerId?.split('/').pop();
|
||||||
|
if (!subject) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return subject === session.user.subject;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!lookup) {
|
||||||
|
throw data(
|
||||||
|
`User with subject ${session.user.subject} not found within Headscale`,
|
||||||
|
404,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||||
|
'v1/preauthkey',
|
||||||
|
session.api_key,
|
||||||
|
{
|
||||||
|
user: lookup.id,
|
||||||
|
reusable: false,
|
||||||
|
ephemeral: true,
|
||||||
|
expiration: new Date(Date.now() + 60 * 1000).toISOString(), // 1 minute
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// TODO: Enable config to enforce generate_authkeys capability
|
||||||
|
// For now, any user is capable of WebSSH connections
|
||||||
|
// const check = await context.sessions.check(
|
||||||
|
// request,
|
||||||
|
// Capabilities.generate_authkeys,
|
||||||
|
// );
|
||||||
|
|
||||||
|
const qp = new URL(request.url).searchParams;
|
||||||
|
const username = qp.get('username') || undefined;
|
||||||
|
const hostname = qp.get('hostname') || undefined;
|
||||||
|
if (!hostname) {
|
||||||
|
throw data('Missing required parameter: hostname', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!username) {
|
||||||
|
return {
|
||||||
|
ipnDetails: undefined,
|
||||||
|
sshDetails: {
|
||||||
|
username,
|
||||||
|
hostname,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// We're making a request to <url>/key?v=116 to check the CORS headers
|
||||||
|
const u = context.config.headscale.public_url ?? context.config.headscale.url;
|
||||||
|
// const res = await fetch(`${u}/key?v=116`, {
|
||||||
|
// method: 'GET',
|
||||||
|
// });
|
||||||
|
|
||||||
|
// const corsOrigin = res.headers.get('Access-Control-Allow-Origin');
|
||||||
|
// const corsMethods = res.headers.get('Access-Control-Allow-Methods');
|
||||||
|
// const corsHeaders = res.headers.get('Access-Control-Allow-Headers');
|
||||||
|
// console.log(corsOrigin, corsMethods, corsHeaders);
|
||||||
|
|
||||||
|
// if (!corsOrigin || !corsMethods || !corsHeaders) {
|
||||||
|
// throw data(
|
||||||
|
// 'Headscale server does not have the required CORS headers for WebSSH',
|
||||||
|
// 500,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
|
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||||
|
'v1/node',
|
||||||
|
session.api_key,
|
||||||
|
);
|
||||||
|
|
||||||
|
// node.name is the hostname, given_name is the set name
|
||||||
|
const lookupNode = nodes.find((n) => n.name === hostname);
|
||||||
|
if (!lookupNode) {
|
||||||
|
throw data(`Node with hostname ${hostname} not found`, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last thing is keeping track of the ephemeral node in the database
|
||||||
|
// because Headscale doesn't automatically delete ephemeral nodes???
|
||||||
|
const [_ephemeralNode] = await context.db
|
||||||
|
.insert(ephemeralNodes)
|
||||||
|
.values({
|
||||||
|
auth_key: preAuthKey.key,
|
||||||
|
} satisfies EphemeralNodeInsert)
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
return {
|
||||||
|
ipnDetails: {
|
||||||
|
PreAuthKey: preAuthKey.key,
|
||||||
|
Hostname: generateHostname(username),
|
||||||
|
ControlURL: u,
|
||||||
|
},
|
||||||
|
|
||||||
|
sshDetails: {
|
||||||
|
username,
|
||||||
|
hostname,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateHostname(username: string) {
|
||||||
|
const adjective = faker.word.adjective({
|
||||||
|
length: {
|
||||||
|
min: 3,
|
||||||
|
max: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const noun = faker.word.noun({
|
||||||
|
length: {
|
||||||
|
min: 3,
|
||||||
|
max: 6,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return `ssh-${adjective}-${noun}-${username}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function action({
|
||||||
|
request,
|
||||||
|
context,
|
||||||
|
}: ActionFunctionArgs<LoadContext>) {
|
||||||
|
const _session = await context.sessions.auth(request);
|
||||||
|
if (!context.agents?.agentID()) {
|
||||||
|
throw data(
|
||||||
|
'WebSSH is only available with the Headplane agent integration',
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = await request.formData();
|
||||||
|
const nodeKey = form.get('node_key');
|
||||||
|
const authKey = form.get('auth_key');
|
||||||
|
|
||||||
|
if (nodeKey === null || typeof nodeKey !== 'string') {
|
||||||
|
throw data('Missing node_key', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authKey === null || typeof authKey !== 'string') {
|
||||||
|
throw data('Missing auth_key', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
await context.db
|
||||||
|
.update(ephemeralNodes)
|
||||||
|
.set({
|
||||||
|
node_key: nodeKey,
|
||||||
|
})
|
||||||
|
.where(eq(ephemeralNodes.auth_key, authKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const links: LinksFunction = () => [
|
||||||
|
{
|
||||||
|
rel: 'preload',
|
||||||
|
href: '/hp_ssh.wasm',
|
||||||
|
as: 'fetch',
|
||||||
|
type: 'application/wasm',
|
||||||
|
crossOrigin: 'anonymous',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const handle: ExternalScriptsHandle = {
|
||||||
|
scripts: [
|
||||||
|
{
|
||||||
|
src: '/wasm_exec.js',
|
||||||
|
crossOrigin: 'anonymous',
|
||||||
|
preload: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
const submit = useSubmit();
|
||||||
|
const { pause } = useLiveData();
|
||||||
|
|
||||||
|
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
||||||
|
const [nodeKey, setNodeKey] = useState<string | null>(null);
|
||||||
|
const { ipnDetails, sshDetails } = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!ipnDetails) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
pause();
|
||||||
|
const go = new Go(); // Go is defined by wasm_exec.js
|
||||||
|
WebAssembly.instantiateStreaming(
|
||||||
|
fetch('/hp_ssh.wasm'),
|
||||||
|
go.importObject,
|
||||||
|
).then((value) => {
|
||||||
|
go.run(value.instance);
|
||||||
|
const handle = TsWasmNet(ipnDetails, {
|
||||||
|
NotifyState: (state) => {
|
||||||
|
console.log('State changed:', state);
|
||||||
|
if (state === 'Running') {
|
||||||
|
setIpn(handle);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
NotifyNetMap: (netmap) => {
|
||||||
|
// Only set NodeKey if it is not already set and then
|
||||||
|
// also dispatch that to the backend to track the
|
||||||
|
// ephemeral node.
|
||||||
|
//
|
||||||
|
// We open an SSE connection to the backend
|
||||||
|
// so that when the connection is closed,
|
||||||
|
// the backend can delete the ephemeral node.
|
||||||
|
if (nodeKey === null) {
|
||||||
|
setNodeKey(netmap.NodeKey);
|
||||||
|
submit(
|
||||||
|
{
|
||||||
|
node_key: netmap.NodeKey,
|
||||||
|
auth_key: ipnDetails.PreAuthKey,
|
||||||
|
},
|
||||||
|
{ method: 'POST' },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
NotifyBrowseToURL: (url) => {
|
||||||
|
console.log('Browse to URL:', url);
|
||||||
|
},
|
||||||
|
NotifyPanicRecover: (message) => {
|
||||||
|
console.error('Panic recover:', message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
handle.Start();
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!sshDetails.username) {
|
||||||
|
return <UserPrompt hostname={sshDetails.hostname} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-screen h-screen bg-headplane-900">
|
||||||
|
{ipn === null ? (
|
||||||
|
<div className="mx-auto h-screen flex items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin size-10 text-headplane-50" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col h-screen">
|
||||||
|
<XTerm
|
||||||
|
hostname={sshDetails.hostname}
|
||||||
|
ipn={ipn}
|
||||||
|
username={sshDetails.username}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+57
@@ -0,0 +1,57 @@
|
|||||||
|
declare function TsWasmNet(
|
||||||
|
options: TsWasmNetOptions,
|
||||||
|
callbacks: TsWasmNetCallbacks,
|
||||||
|
): TsWasmNet;
|
||||||
|
|
||||||
|
interface TsWasmNetOptions {
|
||||||
|
ControlURL: string;
|
||||||
|
PreAuthKey: string;
|
||||||
|
Hostname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TsWasmNetCallbacks {
|
||||||
|
NotifyState: (state: IPNState) => void;
|
||||||
|
NotifyNetMap: (netmap: TsWasmNetMap) => void;
|
||||||
|
NotifyBrowseToURL: (url: string) => void;
|
||||||
|
NotifyPanicRecover: (err: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TsWasmNetMap {
|
||||||
|
NodeKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TsWasmNet {
|
||||||
|
Start: () => void;
|
||||||
|
OpenSSH: (
|
||||||
|
hostname: string,
|
||||||
|
username: string,
|
||||||
|
options: XtermConfig,
|
||||||
|
) => SSHSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
type IPNState =
|
||||||
|
| 'NoState'
|
||||||
|
| 'InUseOtherUser'
|
||||||
|
| 'NeedsLogin'
|
||||||
|
| 'NeedsMachineAuth'
|
||||||
|
| 'Stopped'
|
||||||
|
| 'Starting'
|
||||||
|
| 'Running';
|
||||||
|
|
||||||
|
interface XtermConfig {
|
||||||
|
rows: number;
|
||||||
|
cols: number;
|
||||||
|
timeout?: number;
|
||||||
|
|
||||||
|
onStdout: (data: Uint8Array) => void;
|
||||||
|
onStderr: (data: Uint8Array) => void;
|
||||||
|
onStdin: (func: (input: Uint8Array) => void) => void;
|
||||||
|
|
||||||
|
onConnect: () => void;
|
||||||
|
onDisconnect: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SSHSession {
|
||||||
|
Close(): boolean;
|
||||||
|
Resize(rows: number, cols: number): boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import Button from '~/components/Button';
|
||||||
|
import Card from '~/components/Card';
|
||||||
|
import Code from '~/components/Code';
|
||||||
|
import Input from '~/components/Input';
|
||||||
|
|
||||||
|
interface UserPromptProps {
|
||||||
|
hostname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UserPrompt({ hostname }: UserPromptProps) {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-screen">
|
||||||
|
<Card>
|
||||||
|
<Card.Title>Enter Username</Card.Title>
|
||||||
|
<Card.Text className="mb-4">
|
||||||
|
Enter the username you want to use to connect to{' '}
|
||||||
|
<Code>{hostname}</Code>
|
||||||
|
{'. '}
|
||||||
|
WebSSH follows the Headscale ACLs, so only permitted usernames will be
|
||||||
|
able to connect.
|
||||||
|
</Card.Text>
|
||||||
|
<Input
|
||||||
|
labelHidden
|
||||||
|
type="text"
|
||||||
|
label="Username"
|
||||||
|
placeholder="Username"
|
||||||
|
className="mb-2"
|
||||||
|
onChange={setUsername}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="heavy"
|
||||||
|
className="w-full"
|
||||||
|
onPress={() => {
|
||||||
|
// We can't use the navigate hook here as we need to do a
|
||||||
|
// full page reload to ensure the SSH connection is established
|
||||||
|
window.location.href = `${__PREFIX__}/ssh?hostname=${hostname}&username=${username}`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+7
@@ -0,0 +1,7 @@
|
|||||||
|
declare class Go {
|
||||||
|
importObject: WebAssembly.Imports;
|
||||||
|
run(instance: WebAssembly.Instance): Promise<void>;
|
||||||
|
argv?: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
exit?: (code: number) => void;
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
import { ClipboardAddon } from '@xterm/addon-clipboard';
|
||||||
|
import { FitAddon } from '@xterm/addon-fit';
|
||||||
|
import { Unicode11Addon } from '@xterm/addon-unicode11';
|
||||||
|
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||||
|
import * as xterm from '@xterm/xterm';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import cn from '~/utils/cn';
|
||||||
|
import { useLiveData } from '~/utils/live-data';
|
||||||
|
import toast from '~/utils/toast';
|
||||||
|
|
||||||
|
import '@xterm/xterm/css/xterm.css';
|
||||||
|
|
||||||
|
interface XTermProps {
|
||||||
|
ipn: TsWasmNet;
|
||||||
|
username: string;
|
||||||
|
hostname: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Go's WASM -> JS crosses realms so we might have to normalize the data under
|
||||||
|
// certain conditions. This also enforces bytes instead of strings being sent.
|
||||||
|
function normU8(data: unknown) {
|
||||||
|
if (data instanceof Uint8Array) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data && typeof data === 'object') {
|
||||||
|
const any = data as {
|
||||||
|
buffer?: ArrayBufferLike;
|
||||||
|
byteOffset?: number;
|
||||||
|
byteLength?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
any.buffer instanceof ArrayBuffer &&
|
||||||
|
typeof any.byteLength === 'number'
|
||||||
|
) {
|
||||||
|
return new Uint8Array(
|
||||||
|
any.buffer.slice(
|
||||||
|
any.byteOffset ?? 0,
|
||||||
|
(any.byteOffset ?? 0) + any.byteLength,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Data is not a Uint8Array or ArrayBuffer-like object');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function XTerm({ ipn, username, hostname }: XTermProps) {
|
||||||
|
const { pause } = useLiveData();
|
||||||
|
|
||||||
|
const genRef = useRef(0);
|
||||||
|
const termRef = useRef<xterm.Terminal>(null);
|
||||||
|
const roRef = useRef<ResizeObserver>(null);
|
||||||
|
const inputRef = useRef<(input: Uint8Array) => void>(null);
|
||||||
|
const sshRef = useRef<SSHSession>(null);
|
||||||
|
const divRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const [isResizing, setIsResizing] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pause();
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!divRef.current) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentGen = ++genRef.current;
|
||||||
|
const term = new xterm.Terminal({
|
||||||
|
allowProposedApi: true,
|
||||||
|
cursorBlink: true,
|
||||||
|
convertEol: true,
|
||||||
|
fontSize: 14,
|
||||||
|
});
|
||||||
|
|
||||||
|
const fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
|
||||||
|
term.loadAddon(new Unicode11Addon());
|
||||||
|
term.loadAddon(new ClipboardAddon());
|
||||||
|
term.loadAddon(
|
||||||
|
new WebLinksAddon((event, uri) => {
|
||||||
|
event.view?.open(uri, '_blank', 'noopener noreferrer');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
term.unicode.activeVersion = '11';
|
||||||
|
termRef.current = term;
|
||||||
|
term.open(divRef.current!);
|
||||||
|
fit.fit();
|
||||||
|
term.focus();
|
||||||
|
|
||||||
|
const session = ipn.OpenSSH(hostname, username, {
|
||||||
|
rows: term.rows,
|
||||||
|
cols: term.cols,
|
||||||
|
onStdout: (data) => {
|
||||||
|
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||||
|
console.warn('Stale terminal instance, ignoring stdout');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = normU8(data);
|
||||||
|
term.write(text);
|
||||||
|
},
|
||||||
|
onStderr: (data) => {
|
||||||
|
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||||
|
console.warn('Stale terminal instance, ignoring stderr');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = normU8(data);
|
||||||
|
term.write(text);
|
||||||
|
const str = new TextDecoder().decode(text);
|
||||||
|
setError(str);
|
||||||
|
},
|
||||||
|
onStdin: (func) => {
|
||||||
|
inputRef.current = func;
|
||||||
|
},
|
||||||
|
onConnect: () => {
|
||||||
|
if (currentGen !== genRef.current) {
|
||||||
|
console.warn('Stale terminal instance, ignoring onConnect');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
},
|
||||||
|
onDisconnect: () => {
|
||||||
|
if (currentGen !== genRef.current) {
|
||||||
|
console.warn('Stale terminal instance, ignoring onDisconnect');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
roRef.current?.disconnect();
|
||||||
|
term.dispose();
|
||||||
|
termRef.current = null;
|
||||||
|
inputRef.current = null;
|
||||||
|
sshRef.current = null;
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
sshRef.current = session;
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
term.onData((data) => {
|
||||||
|
if (currentGen !== genRef.current) {
|
||||||
|
console.warn('Stale terminal instance, ignoring onData');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = enc.encode(data);
|
||||||
|
inputRef.current?.(bytes);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
if (currentGen !== genRef.current || term !== termRef.current) {
|
||||||
|
console.warn('Stale terminal instance, ignoring resize');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsResizing(true);
|
||||||
|
fit.fit();
|
||||||
|
sshRef.current?.Resize(term.cols, term.rows);
|
||||||
|
setTimeout(() => setIsResizing(false), 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
roRef.current = ro;
|
||||||
|
ro.observe(divRef.current!);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
++genRef.current;
|
||||||
|
roRef.current?.disconnect();
|
||||||
|
roRef.current = null;
|
||||||
|
|
||||||
|
sshRef.current?.Close();
|
||||||
|
sshRef.current = null;
|
||||||
|
|
||||||
|
term.dispose();
|
||||||
|
if (termRef.current === term) {
|
||||||
|
termRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
inputRef.current = null;
|
||||||
|
};
|
||||||
|
}, [ipn, username, hostname]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="absolute w-screen z-50 mx-auto h-screen flex items-center justify-center">
|
||||||
|
<Loader2 className="animate-spin size-10 text-headplane-50" />
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
<div
|
||||||
|
className={cn('w-full h-full', isLoading ? 'opacity-0' : 'opacity-100')}
|
||||||
|
ref={divRef}
|
||||||
|
/>
|
||||||
|
{termRef.current && isResizing ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
|
||||||
|
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{termRef.current.cols}x{termRef.current.rows}
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
{error !== null ? (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center',
|
||||||
|
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Failed to connect to SSH session
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : undefined}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,14 +26,14 @@ export default function UserRow({ user, role }: UserRowProps) {
|
|||||||
{user.profilePicUrl ? (
|
{user.profilePicUrl ? (
|
||||||
<img
|
<img
|
||||||
src={user.profilePicUrl}
|
src={user.profilePicUrl}
|
||||||
alt={user.name}
|
alt={user.name || user.displayName}
|
||||||
className="w-10 h-10 rounded-full"
|
className="w-10 h-10 rounded-full"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CircleUser className="w-10 h-10" />
|
<CircleUser className="w-10 h-10" />
|
||||||
)}
|
)}
|
||||||
<div className="ml-4">
|
<div className="ml-4">
|
||||||
<p className={cn('font-semibold leading-snug')}>{user.name}</p>
|
<p className={cn('font-semibold leading-snug')}>{user.name || user.displayName}</p>
|
||||||
<p className="text-sm opacity-50">{user.email}</p>
|
<p className="text-sm opacity-50">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ interface DeleteProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
|
export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
|
||||||
const name =
|
const name = user.name || user.displayName;
|
||||||
(user.displayName?.length ?? 0) > 0 ? user.displayName : user.name;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export default function ReassignUser({
|
|||||||
<Dialog.Panel
|
<Dialog.Panel
|
||||||
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
|
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
|
||||||
>
|
>
|
||||||
<Dialog.Title>Change role for {user.name}?</Dialog.Title>
|
<Dialog.Title>Change role for {user.name || user.displayName}?</Dialog.Title>
|
||||||
<Dialog.Text className="mb-6">
|
<Dialog.Text className="mb-6">
|
||||||
Most roles are carried straight from Tailscale. However, keep in mind
|
Most roles are carried straight from Tailscale. However, keep in mind
|
||||||
that I have not fully implemented permissions yet and some things may
|
that I have not fully implemented permissions yet and some things may
|
||||||
|
|||||||
@@ -13,9 +13,9 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
|
|||||||
return (
|
return (
|
||||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Panel>
|
<Dialog.Panel>
|
||||||
<Dialog.Title>Rename {user.name}?</Dialog.Title>
|
<Dialog.Title>Rename {user.name || user.displayName}?</Dialog.Title>
|
||||||
<Dialog.Text className="mb-6">
|
<Dialog.Text className="mb-6">
|
||||||
Enter a new username for {user.name}. Changing a username will not
|
Enter a new username for {user.name || user.displayName}. Changing a username will not
|
||||||
update any ACL policies that may refer to this user by their old
|
update any ACL policies that may refer to this user by their old
|
||||||
username.
|
username.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
|
|||||||
@@ -1,16 +1,23 @@
|
|||||||
|
import { eq } from 'drizzle-orm';
|
||||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||||
import { LoadContext } from '~/server';
|
import { LoadContext } from '~/server';
|
||||||
|
import { users } from '~/server/db/schema';
|
||||||
|
|
||||||
export async function loader({
|
export async function loader({
|
||||||
request,
|
request,
|
||||||
context,
|
context,
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
}: LoaderFunctionArgs<LoadContext>) {
|
||||||
const session = await context.sessions.auth(request);
|
try {
|
||||||
const user = session.get('user');
|
const { user } = await context.sessions.auth(request);
|
||||||
if (!user) {
|
await context.db
|
||||||
|
.update(users)
|
||||||
|
.set({
|
||||||
|
onboarded: true,
|
||||||
|
})
|
||||||
|
.where(eq(users.sub, user.subject));
|
||||||
|
|
||||||
|
return redirect('/machines');
|
||||||
|
} catch {
|
||||||
return redirect('/login');
|
return redirect('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
context.sessions.overrideOnboarding(user.subject, true);
|
|
||||||
return redirect('/machines');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
|
import { Icon } from '@iconify/react';
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from 'lucide-react';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { GrApple } from 'react-icons/gr';
|
import { LoaderFunctionArgs, NavLink, useLoaderData } from 'react-router';
|
||||||
import { ImFinder } from 'react-icons/im';
|
|
||||||
import { MdAndroid } from 'react-icons/md';
|
|
||||||
import { PiTerminalFill, PiWindowsLogoFill } from 'react-icons/pi';
|
|
||||||
import {
|
|
||||||
LoaderFunctionArgs,
|
|
||||||
NavLink,
|
|
||||||
redirect,
|
|
||||||
useLoaderData,
|
|
||||||
} from 'react-router';
|
|
||||||
import Button from '~/components/Button';
|
import Button from '~/components/Button';
|
||||||
import Card from '~/components/Card';
|
import Card from '~/components/Card';
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
@@ -27,10 +19,6 @@ export async function loader({
|
|||||||
context,
|
context,
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
}: LoaderFunctionArgs<LoadContext>) {
|
||||||
const session = await context.sessions.auth(request);
|
const session = await context.sessions.auth(request);
|
||||||
const user = session.get('user');
|
|
||||||
if (!user) {
|
|
||||||
return redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
|
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
|
||||||
// We need to convert this to a known value to return it to the client so we can
|
// We need to convert this to a known value to return it to the client so we can
|
||||||
@@ -60,11 +48,11 @@ export async function loader({
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let firstMachine: Machine | undefined = undefined;
|
let firstMachine: Machine | undefined;
|
||||||
try {
|
try {
|
||||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||||
'v1/node',
|
'v1/node',
|
||||||
session.get('api_key')!,
|
session.api_key,
|
||||||
);
|
);
|
||||||
|
|
||||||
const node = nodes.find((n) => {
|
const node = nodes.find((n) => {
|
||||||
@@ -79,12 +67,7 @@ export async function loader({
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionUser = session.get('user');
|
if (subject !== session.user.subject) {
|
||||||
if (!sessionUser) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subject !== sessionUser.subject) {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,7 +81,7 @@ export async function loader({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
user,
|
user: session.user,
|
||||||
osValue,
|
osValue,
|
||||||
firstMachine,
|
firstMachine,
|
||||||
};
|
};
|
||||||
@@ -126,7 +109,7 @@ export default function Page() {
|
|||||||
return (
|
return (
|
||||||
<div className="fixed w-full h-screen flex items-center px-4">
|
<div className="fixed w-full h-screen flex items-center px-4">
|
||||||
<div className="w-fit mx-auto grid grid-cols-1 md:grid-cols-2 gap-4 mb-24">
|
<div className="w-fit mx-auto grid grid-cols-1 md:grid-cols-2 gap-4 mb-24">
|
||||||
<Card variant="flat" className="max-w-lg">
|
<Card className="max-w-lg" variant="flat">
|
||||||
<Card.Title className="mb-8">
|
<Card.Title className="mb-8">
|
||||||
Welcome!
|
Welcome!
|
||||||
<br />
|
<br />
|
||||||
@@ -138,15 +121,15 @@ export default function Page() {
|
|||||||
</Card.Text>
|
</Card.Text>
|
||||||
|
|
||||||
<Options
|
<Options
|
||||||
|
className="my-4"
|
||||||
defaultSelectedKey={osValue}
|
defaultSelectedKey={osValue}
|
||||||
label="Download Selector"
|
label="Download Selector"
|
||||||
className="my-4"
|
|
||||||
>
|
>
|
||||||
<Options.Item
|
<Options.Item
|
||||||
key="linux"
|
key="linux"
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<PiTerminalFill className="ml-1 w-4" />
|
<Icon className="ml-1 w-4" icon="ion:terminal" />
|
||||||
<span>Linux</span>
|
<span>Linux</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -177,18 +160,18 @@ export default function Page() {
|
|||||||
key="windows"
|
key="windows"
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<PiWindowsLogoFill className="ml-1 w-4" />
|
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
|
||||||
<span>Windows</span>
|
<span>Windows</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
|
||||||
aria-label="Download for Windows"
|
aria-label="Download for Windows"
|
||||||
target="_blank"
|
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Button variant="heavy" className="my-4 w-full">
|
<Button className="my-4 w-full" variant="heavy">
|
||||||
Download for Windows
|
Download for Windows
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
@@ -200,18 +183,21 @@ export default function Page() {
|
|||||||
key="macos"
|
key="macos"
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ImFinder className="ml-1 w-4" />
|
<Icon
|
||||||
|
className="ml-1 w-4"
|
||||||
|
icon="streamline-logos:mac-finder-logo-solid"
|
||||||
|
/>
|
||||||
<span>macOS</span>
|
<span>macOS</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
|
||||||
aria-label="Download for macOS"
|
aria-label="Download for macOS"
|
||||||
target="_blank"
|
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Button variant="heavy" className="my-4 w-full">
|
<Button className="my-4 w-full" variant="heavy">
|
||||||
Download for macOS
|
Download for macOS
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
@@ -232,18 +218,18 @@ export default function Page() {
|
|||||||
key="ios"
|
key="ios"
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<GrApple className="ml-1 w-4" />
|
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
|
||||||
<span>iOS</span>
|
<span>iOS</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="https://apps.apple.com/us/app/tailscale/id1470499037"
|
|
||||||
aria-label="Download for iOS"
|
aria-label="Download for iOS"
|
||||||
target="_blank"
|
href="https://apps.apple.com/us/app/tailscale/id1470499037"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Button variant="heavy" className="my-4 w-full">
|
<Button className="my-4 w-full" variant="heavy">
|
||||||
Download for iOS
|
Download for iOS
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
@@ -255,18 +241,18 @@ export default function Page() {
|
|||||||
key="android"
|
key="android"
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<MdAndroid className="ml-1 w-4" />
|
<Icon className="ml-1 w-4" icon="material-symbols:android" />
|
||||||
<span>Android</span>
|
<span>Android</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
|
|
||||||
aria-label="Download for Android"
|
aria-label="Download for Android"
|
||||||
target="_blank"
|
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Button variant="heavy" className="my-4 w-full">
|
<Button className="my-4 w-full" variant="heavy">
|
||||||
Download for Android
|
Download for Android
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
@@ -287,8 +273,8 @@ export default function Page() {
|
|||||||
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4">
|
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<StatusCircle
|
<StatusCircle
|
||||||
isOnline={firstMachine.online}
|
|
||||||
className="size-6 mt-3"
|
className="size-6 mt-3"
|
||||||
|
isOnline={firstMachine.online}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<p className="font-semibold leading-snug">
|
<p className="font-semibold leading-snug">
|
||||||
@@ -300,7 +286,7 @@ export default function Page() {
|
|||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<p className="text-sm font-semibold">IP Addresses</p>
|
<p className="text-sm font-semibold">IP Addresses</p>
|
||||||
{firstMachine.ipAddresses.map((ip) => (
|
{firstMachine.ipAddresses.map((ip) => (
|
||||||
<p key={ip} className="text-xs font-mono opacity-50">
|
<p className="text-xs font-mono opacity-50" key={ip}>
|
||||||
{ip}
|
{ip}
|
||||||
</p>
|
</p>
|
||||||
))}
|
))}
|
||||||
@@ -308,8 +294,8 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NavLink to="/">
|
<NavLink to="/onboarding/skip">
|
||||||
<Button variant="heavy" className="w-full">
|
<Button className="w-full" variant="heavy">
|
||||||
Continue
|
Continue
|
||||||
</Button>
|
</Button>
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -335,7 +321,7 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
<NavLink to="/onboarding/skip" className="col-span-2 w-max mx-auto">
|
<NavLink className="col-span-2 w-max mx-auto" to="/onboarding/skip">
|
||||||
<Button className="flex items-center gap-1">
|
<Button className="flex items-center gap-1">
|
||||||
I already know what I'm doing
|
I already know what I'm doing
|
||||||
<ArrowRight className="p-1" />
|
<ArrowRight className="p-1" />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||||
import { useLoaderData, useSubmit } from 'react-router';
|
import { useLoaderData } from 'react-router';
|
||||||
import type { LoadContext } from '~/server';
|
import type { LoadContext } from '~/server';
|
||||||
import { Capabilities } from '~/server/web/roles';
|
import { Capabilities } from '~/server/web/roles';
|
||||||
import { Machine, User } from '~/types';
|
import { Machine, User } from '~/types';
|
||||||
@@ -32,11 +32,8 @@ export async function loader({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const [machines, apiUsers] = await Promise.all([
|
const [machines, apiUsers] = await Promise.all([
|
||||||
context.client.get<{ nodes: Machine[] }>(
|
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
|
||||||
'v1/node',
|
context.client.get<{ users: User[] }>('v1/user', session.api_key),
|
||||||
session.get('api_key')!,
|
|
||||||
),
|
|
||||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const users = apiUsers.users.map((user) => ({
|
const users = apiUsers.users.map((user) => ({
|
||||||
@@ -44,30 +41,32 @@ export async function loader({
|
|||||||
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
|
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const roles = users
|
const roles = await Promise.all(
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
users
|
||||||
.map((user) => {
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
if (user.provider !== 'oidc') {
|
.map(async (user) => {
|
||||||
return 'no-oidc';
|
if (user.provider !== 'oidc') {
|
||||||
}
|
return 'no-oidc';
|
||||||
|
|
||||||
if (user.provider === 'oidc' && user.providerId) {
|
|
||||||
// For some reason, headscale makes providerID a url where the
|
|
||||||
// last component is the subject, so we need to strip that out
|
|
||||||
const subject = user.providerId.split('/').pop();
|
|
||||||
if (!subject) {
|
|
||||||
return 'invalid-oidc';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = context.sessions.roleForSubject(subject);
|
if (user.provider === 'oidc' && user.providerId) {
|
||||||
return role ?? 'no-role';
|
// For some reason, headscale makes providerID a url where the
|
||||||
}
|
// last component is the subject, so we need to strip that out
|
||||||
|
const subject = user.providerId.split('/').pop();
|
||||||
|
if (!subject) {
|
||||||
|
return 'invalid-oidc';
|
||||||
|
}
|
||||||
|
|
||||||
// No role means the user is not registered in Headplane, but they
|
const role = await context.sessions.roleForSubject(subject);
|
||||||
// are in Headscale. We also need to handle what happens if someone
|
return role ?? 'no-role';
|
||||||
// logs into the UI and they don't have a Headscale setup.
|
}
|
||||||
return 'no-role';
|
|
||||||
});
|
// No role means the user is not registered in Headplane, but they
|
||||||
|
// are in Headscale. We also need to handle what happens if someone
|
||||||
|
// logs into the UI and they don't have a Headscale setup.
|
||||||
|
return 'no-role';
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
let magic: string | undefined;
|
let magic: string | undefined;
|
||||||
if (context.hs.readable()) {
|
if (context.hs.readable()) {
|
||||||
@@ -105,10 +104,9 @@ export default function Page() {
|
|||||||
<>
|
<>
|
||||||
<h1 className="text-2xl font-medium mb-1.5">Users</h1>
|
<h1 className="text-2xl font-medium mb-1.5">Users</h1>
|
||||||
<p className="mb-8 text-md">
|
<p className="mb-8 text-md">
|
||||||
Manage the users in your network and their permissions. Tip: You can
|
Manage the users in your network and their permissions.
|
||||||
drag machines between users to change ownership.
|
|
||||||
</p>
|
</p>
|
||||||
<ManageBanner oidc={data.oidc} isDisabled={!data.writable} />
|
<ManageBanner isDisabled={!data.writable} oidc={data.oidc} />
|
||||||
<table className="table-auto w-full rounded-lg">
|
<table className="table-auto w-full rounded-lg">
|
||||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||||
<tr className="text-left px-0.5">
|
<tr className="text-left px-0.5">
|
||||||
@@ -129,8 +127,8 @@ export default function Page() {
|
|||||||
.map((user) => (
|
.map((user) => (
|
||||||
<UserRow
|
<UserRow
|
||||||
key={user.id}
|
key={user.id}
|
||||||
user={user}
|
|
||||||
role={data.roles[users.indexOf(user)]}
|
role={data.roles[users.indexOf(user)]}
|
||||||
|
user={user}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ActionFunctionArgs, Session, data } from 'react-router';
|
import { ActionFunctionArgs, data } from 'react-router';
|
||||||
import type { LoadContext } from '~/server';
|
import type { LoadContext } from '~/server';
|
||||||
import { Capabilities, Roles } from '~/server/web/roles';
|
import { Capabilities, Roles } from '~/server/web/roles';
|
||||||
import { AuthSession } from '~/server/web/sessions';
|
|
||||||
import { User } from '~/types';
|
import { User } from '~/types';
|
||||||
import { data400, data403 } from '~/utils/res';
|
import { data400, data403 } from '~/utils/res';
|
||||||
|
|
||||||
@@ -15,7 +14,7 @@ export async function userAction({
|
|||||||
throw data403('You do not have permission to update users');
|
throw data403('You do not have permission to update users');
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = session.get('api_key')!;
|
const apiKey = session.api_key;
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const action = formData.get('action_id')?.toString();
|
const action = formData.get('action_id')?.toString();
|
||||||
if (!action) {
|
if (!action) {
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ many side-effects (in this case, importing a module may run code).
|
|||||||
```
|
```
|
||||||
server
|
server
|
||||||
├── index.ts: Loads everything and starts the web server.
|
├── index.ts: Loads everything and starts the web server.
|
||||||
|
├── agent/
|
||||||
|
│ ├── dispatcher.ts: Serializes commands for the agent control fd (stdin).
|
||||||
|
│ ├── ssh.ts: Manages & multiplexes the active web SSH connections
|
||||||
|
│ ├── env.ts: Checks the environment variables for custom overrides.
|
||||||
├── config/
|
├── config/
|
||||||
│ ├── integration/
|
│ ├── integration/
|
||||||
│ │ ├── abstract.ts: Defines the abstract class for integrations.
|
│ │ ├── abstract.ts: Defines the abstract class for integrations.
|
||||||
|
|||||||
@@ -3,13 +3,18 @@ import { platform } from 'node:os';
|
|||||||
import { join, resolve } from 'node:path';
|
import { join, resolve } from 'node:path';
|
||||||
import { kill } from 'node:process';
|
import { kill } from 'node:process';
|
||||||
import { setTimeout } from 'node:timers/promises';
|
import { setTimeout } from 'node:timers/promises';
|
||||||
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
||||||
import { ApiClient } from '~/server/headscale/api-client';
|
import { ApiClient } from '~/server/headscale/api-client';
|
||||||
import log from '~/utils/log';
|
import log from '~/utils/log';
|
||||||
import { HeadplaneConfig } from '../schema';
|
import { HeadplaneConfig } from '../schema';
|
||||||
import { Integration } from './abstract';
|
import { Integration } from './abstract';
|
||||||
|
|
||||||
// TODO: Upgrade to the new CoreV1Api from @kubernetes/client-node
|
// https://github.com/kubernetes-client/javascript/blob/055b83c6504dfd1b2a2d081efd974163c6cbb808/src/config.ts#L40
|
||||||
|
const svcRoot = '/var/run/secrets/kubernetes.io/serviceaccount';
|
||||||
|
const svcCaPath = `${svcRoot}/ca.crt`;
|
||||||
|
const svcTokenPath = `${svcRoot}/token`;
|
||||||
|
const svcNamespacePath = `${svcRoot}/namespace`;
|
||||||
|
|
||||||
type T = NonNullable<HeadplaneConfig['integration']>['kubernetes'];
|
type T = NonNullable<HeadplaneConfig['integration']>['kubernetes'];
|
||||||
export default class KubernetesIntegration extends Integration<T> {
|
export default class KubernetesIntegration extends Integration<T> {
|
||||||
private pid: number | undefined;
|
private pid: number | undefined;
|
||||||
@@ -25,7 +30,6 @@ export default class KubernetesIntegration extends Integration<T> {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const svcRoot = Config.SERVICEACCOUNT_ROOT;
|
|
||||||
try {
|
try {
|
||||||
log.debug('config', 'Checking Kubernetes service account at %s', svcRoot);
|
log.debug('config', 'Checking Kubernetes service account at %s', svcRoot);
|
||||||
const files = await readdir(svcRoot);
|
const files = await readdir(svcRoot);
|
||||||
@@ -35,11 +39,7 @@ export default class KubernetesIntegration extends Integration<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
|
const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
|
||||||
const expectedFiles = [
|
const expectedFiles = [svcCaPath, svcTokenPath, svcNamespacePath];
|
||||||
Config.SERVICEACCOUNT_CA_PATH,
|
|
||||||
Config.SERVICEACCOUNT_TOKEN_PATH,
|
|
||||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
|
||||||
];
|
|
||||||
|
|
||||||
log.debug('config', 'Looking for %s', expectedFiles.join(', '));
|
log.debug('config', 'Looking for %s', expectedFiles.join(', '));
|
||||||
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
|
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
|
||||||
@@ -52,10 +52,7 @@ export default class KubernetesIntegration extends Integration<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.debug('config', 'Reading Kubernetes service account at %s', svcRoot);
|
log.debug('config', 'Reading Kubernetes service account at %s', svcRoot);
|
||||||
const namespace = await readFile(
|
const namespace = await readFile(svcNamespacePath, 'utf8');
|
||||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Some very ugly nesting but it's necessary
|
// Some very ugly nesting but it's necessary
|
||||||
if (this.context.validate_manifest === false) {
|
if (this.context.validate_manifest === false) {
|
||||||
@@ -99,36 +96,32 @@ export default class KubernetesIntegration extends Integration<T> {
|
|||||||
|
|
||||||
const kCoreV1Api = kc.makeApiClient(CoreV1Api);
|
const kCoreV1Api = kc.makeApiClient(CoreV1Api);
|
||||||
|
|
||||||
log.info(
|
log.info('config', 'Checking pod %s in namespace %s', pod, namespace);
|
||||||
'config',
|
|
||||||
'Checking pod %s in namespace %s (%s)',
|
|
||||||
pod,
|
|
||||||
namespace,
|
|
||||||
kCoreV1Api.basePath,
|
|
||||||
);
|
|
||||||
|
|
||||||
log.debug('config', 'Reading pod info for %s', pod);
|
log.debug('config', 'Reading pod info for %s', pod);
|
||||||
const { response, body } = await kCoreV1Api.readNamespacedPod(
|
const body = await kCoreV1Api.readNamespacedPod({
|
||||||
pod,
|
name: pod,
|
||||||
namespace,
|
namespace,
|
||||||
);
|
});
|
||||||
|
|
||||||
if (response.statusCode !== 200) {
|
if (!body.spec) {
|
||||||
log.error(
|
log.error(
|
||||||
'config',
|
'config',
|
||||||
'Failed to read pod info: http %d',
|
'Missing spec in pod info for %s/%s',
|
||||||
response.statusCode,
|
pod,
|
||||||
|
namespace,
|
||||||
);
|
);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug('config', 'Got pod info: %o', body.spec);
|
log.debug('config', 'Got pod info: %o', body.spec);
|
||||||
const shared = body.spec?.shareProcessNamespace;
|
const shared = body.spec.shareProcessNamespace;
|
||||||
if (shared === undefined) {
|
if (shared === undefined) {
|
||||||
log.error(
|
log.error(
|
||||||
'config',
|
'config',
|
||||||
'Pod does not have spec.shareProcessNamespace set',
|
'Pod does not have spec.shareProcessNamespace set',
|
||||||
);
|
);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +130,7 @@ export default class KubernetesIntegration extends Integration<T> {
|
|||||||
'config',
|
'config',
|
||||||
'Pod has set but disabled spec.shareProcessNamespace',
|
'Pod has set but disabled spec.shareProcessNamespace',
|
||||||
);
|
);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,12 +152,14 @@ export default class KubernetesIntegration extends Integration<T> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = join('/proc', dir, 'cmdline');
|
const path = join('/proc', dir, 'comm');
|
||||||
try {
|
try {
|
||||||
log.debug('config', 'Reading %s', path);
|
log.debug('config', 'Reading %s', path);
|
||||||
const data = await readFile(path, 'utf8');
|
const data = await readFile(path, 'utf8');
|
||||||
if (!data.includes('headscale') && !data.includes('serve')) {
|
if (data.trim() !== 'headscale') {
|
||||||
throw new Error('Found PID without Headscale serve command');
|
throw new Error(
|
||||||
|
`Found PID with unexpected command: ${data.trim()}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return pid;
|
return pid;
|
||||||
|
|||||||
@@ -34,17 +34,19 @@ export default class ProcIntegration extends Integration<T> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const path = join('/proc', dir, 'cmdline');
|
const path = join('/proc', dir, 'comm');
|
||||||
try {
|
try {
|
||||||
log.debug('config', 'Reading %s', path);
|
log.debug('config', 'Reading %s', path);
|
||||||
const data = await readFile(path, 'utf8');
|
const data = await readFile(path, 'utf8');
|
||||||
if (!data.includes('headscale') && !data.includes('serve')) {
|
if (data.trim() !== 'headscale') {
|
||||||
throw new Error('Found PID without Headscale serve command');
|
throw new Error(
|
||||||
|
`Found PID with unexpected command: ${data.trim()}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return pid;
|
return pid;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('config', 'Failed to read %s: %s', path, error);
|
log.debug('config', 'Failed to read %s: %s', path, error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -59,14 +61,37 @@ export default class ProcIntegration extends Integration<T> {
|
|||||||
|
|
||||||
log.debug('config', 'Found Headscale processes: %o', pids);
|
log.debug('config', 'Found Headscale processes: %o', pids);
|
||||||
if (pids.length > 1) {
|
if (pids.length > 1) {
|
||||||
log.error(
|
log.warn(
|
||||||
'config',
|
'config',
|
||||||
'Found %d Headscale processes: %s',
|
'Found %d Headscale processes: %s',
|
||||||
pids.length,
|
pids.length,
|
||||||
pids.join(', '),
|
pids.join(', '),
|
||||||
);
|
);
|
||||||
return false;
|
|
||||||
}
|
log.debug('config', 'Checking if any of them have Parent PID = 1, assuming thats the correct PID');
|
||||||
|
const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/;
|
||||||
|
for (const pid of pids) {
|
||||||
|
const pidStatusPath = join('/proc', pid.toString(), 'status');
|
||||||
|
try {
|
||||||
|
log.debug('config', 'Reading %s', pidStatusPath);
|
||||||
|
const pidData = await readFile(pidStatusPath, 'utf8');
|
||||||
|
const ppidResult = pidData.match(ppidRegex);
|
||||||
|
|
||||||
|
if (ppidResult !== null) {
|
||||||
|
const potentialPPid = Number.parseInt(ppidResult[1], 10);
|
||||||
|
if (potentialPPid === 1) {
|
||||||
|
this.pid = pid;
|
||||||
|
log.info('config', 'Found potential Headscale process with PID: %d based on Parent PID = 1', this.pid);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error('config', 'Failed to read %s: %s', pidStatusPath, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (pids.length === 0) {
|
if (pids.length === 0) {
|
||||||
log.error('config', 'Could not find Headscale process');
|
log.error('config', 'Could not find Headscale process');
|
||||||
|
|||||||
+119
-41
@@ -1,5 +1,5 @@
|
|||||||
import { constants, access, readFile } from 'node:fs/promises';
|
import { access, constants, readFile } from 'node:fs/promises';
|
||||||
import { env, exit } from 'node:process';
|
import { env } from 'node:process';
|
||||||
import { type } from 'arktype';
|
import { type } from 'arktype';
|
||||||
import { configDotenv } from 'dotenv';
|
import { configDotenv } from 'dotenv';
|
||||||
import { parseDocument } from 'yaml';
|
import { parseDocument } from 'yaml';
|
||||||
@@ -11,6 +11,28 @@ import {
|
|||||||
partialHeadplaneConfig,
|
partialHeadplaneConfig,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
|
// Custom error for config issues
|
||||||
|
export class ConfigError extends Error {
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ConfigError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interpolate environment variables in a string
|
||||||
|
* Replaces ${VAR_NAME} patterns with the actual environment variable values
|
||||||
|
*/
|
||||||
|
export function interpolateEnvVars(str: string): string {
|
||||||
|
return str.replace(/\$\{([^}]+)\}/g, (_, varName) => {
|
||||||
|
const value = env[varName];
|
||||||
|
if (value === undefined) {
|
||||||
|
throw new ConfigError(`Environment variable "${varName}" not found`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// loadConfig is a has a lifetime of the entire application and is
|
// loadConfig is a has a lifetime of the entire application and is
|
||||||
// used to load the configuration for Headplane. It is called once.
|
// used to load the configuration for Headplane. It is called once.
|
||||||
//
|
//
|
||||||
@@ -18,54 +40,108 @@ import {
|
|||||||
// But this may not be necessary as a use-case anyways
|
// But this may not be necessary as a use-case anyways
|
||||||
export async function loadConfig({ loadEnv, path }: EnvOverrides) {
|
export async function loadConfig({ loadEnv, path }: EnvOverrides) {
|
||||||
log.debug('config', 'Loading configuration file: %s', path);
|
log.debug('config', 'Loading configuration file: %s', path);
|
||||||
const valid = await validateConfigPath(path);
|
await validateConfigPath(path);
|
||||||
if (!valid) {
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await loadConfigFile(path);
|
const data = await loadConfigFile(path);
|
||||||
if (!data) {
|
if (!data) {
|
||||||
exit(1);
|
throw new ConfigError('Failed to load configuration file');
|
||||||
}
|
}
|
||||||
|
|
||||||
let config = validateConfig({ ...data, debug: log.debugEnabled });
|
let config = validateConfig({ ...data, debug: log.debugEnabled });
|
||||||
if (!config) {
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!loadEnv) {
|
if (!loadEnv) {
|
||||||
log.debug('config', 'Environment variable overrides are disabled');
|
log.debug('config', 'Environment variable overrides are disabled');
|
||||||
log.debug('config', 'This also disables the loading of a .env file');
|
log.debug('config', 'This also disables the loading of a .env file');
|
||||||
return config;
|
const moddedConfig = await loadSecretsFromFiles(config);
|
||||||
|
log.debug('config', 'Loaded file-based secrets');
|
||||||
|
return moddedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('config', 'Loading a .env file (if available)');
|
log.info('config', 'Loading a .env file (if available)');
|
||||||
configDotenv({ override: true });
|
configDotenv({ override: true, quiet: true });
|
||||||
config = coalesceEnv(config);
|
const merged = coalesceEnv(config);
|
||||||
if (!config) {
|
if (merged) config = merged;
|
||||||
exit(1);
|
if (config.headscale && typeof config.headscale.config_path === 'string') {
|
||||||
|
config.headscale.config_path = interpolateEnvVars(
|
||||||
|
config.headscale.config_path,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return config;
|
const moddedConfig = await loadSecretsFromFiles(config);
|
||||||
|
log.debug('config', 'Loaded file-based secrets');
|
||||||
|
|
||||||
|
return moddedConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hp_loadConfig() {
|
/**
|
||||||
// // OIDC Related Checks
|
* Recursively walks the config object; for any key in the whitelist of secret path keys,
|
||||||
// if (config.oidc) {
|
* reads that file and assigns its contents to the corresponding key
|
||||||
// if (!config.oidc.client_secret && !config.oidc.client_secret_path) {
|
* without the suffix, then removes the "_path" property.
|
||||||
// log.error('CFGX', 'OIDC configuration is missing a secret, disabling');
|
*/
|
||||||
// log.error(
|
const SECRET_PATH_KEYS = [
|
||||||
// 'CFGX',
|
'pre_authkey_path',
|
||||||
// 'Please specify either `oidc.client_secret` or `oidc.client_secret_path`',
|
'client_secret_path',
|
||||||
// );
|
'headscale_api_key_path',
|
||||||
// }
|
'cookie_secret_path',
|
||||||
// if (config.oidc?.strict_validation) {
|
] as const;
|
||||||
// const result = await testOidc(config.oidc);
|
|
||||||
// if (!result) {
|
// For fast set hashing lookups, but we still need the array for typings
|
||||||
// log.error('CFGX', 'OIDC configuration failed validation, disabling');
|
const SECRET_PATH_KEY_SET = new Set<string>(SECRET_PATH_KEYS);
|
||||||
// }
|
|
||||||
// }
|
type SecretPathKey = (typeof SECRET_PATH_KEYS)[number];
|
||||||
// }
|
type StripPath<S extends string> = S extends `${infer T}_path` ? T : never;
|
||||||
|
type KeysToPromote<T> = Extract<keyof T & string, SecretPathKey>;
|
||||||
|
type MappedKeys<T> = StripPath<KeysToPromote<T>>;
|
||||||
|
|
||||||
|
type NonNullablized<T> = Omit<T, KeysToPromote<T> | MappedKeys<T>> & {
|
||||||
|
[K in MappedKeys<T>]-?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NestedNonNullablized<T> = T extends readonly (infer U)[]
|
||||||
|
? readonly NestedNonNullablized<U>[]
|
||||||
|
: T extends (infer U)[]
|
||||||
|
? NestedNonNullablized<U>[]
|
||||||
|
: T extends object
|
||||||
|
? {
|
||||||
|
[K in keyof NonNullablized<T>]: NestedNonNullablized<
|
||||||
|
NonNullablized<T>[K]
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
: T;
|
||||||
|
|
||||||
|
async function loadSecretsFromFiles<T extends object>(
|
||||||
|
obj: T,
|
||||||
|
): Promise<NestedNonNullablized<T>> {
|
||||||
|
// Work with a Record so we can mutate/delete properties
|
||||||
|
const record = obj as Record<string, unknown>;
|
||||||
|
|
||||||
|
for (const key of Object.keys(record)) {
|
||||||
|
const val = record[key];
|
||||||
|
|
||||||
|
if (val && typeof val === 'object') {
|
||||||
|
// recurse into nested objects
|
||||||
|
record[key] = await loadSecretsFromFiles(val);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (SECRET_PATH_KEY_SET.has(key) && typeof val === 'string') {
|
||||||
|
try {
|
||||||
|
const path = interpolateEnvVars(val);
|
||||||
|
const content = await readFile(path, 'utf8');
|
||||||
|
const secretKey = key.slice(0, -5); // drop '_path'
|
||||||
|
record[secretKey] = content.trim();
|
||||||
|
delete record[key];
|
||||||
|
log.debug('config', 'Loaded secret from %s → %s', val, secretKey);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ConfigError) throw err;
|
||||||
|
log.error('config', 'Failed to read secret file %s: %s', val, err);
|
||||||
|
throw new ConfigError(`Failed to read secret file ${val}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cast back to the original T so callers keep their precise type
|
||||||
|
return record as NestedNonNullablized<T>;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function validateConfigPath(path: string) {
|
async function validateConfigPath(path: string) {
|
||||||
@@ -76,7 +152,9 @@ async function validateConfigPath(path: string) {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('config', 'Unable to read a configuration file at %s', path);
|
log.error('config', 'Unable to read a configuration file at %s', path);
|
||||||
log.error('config', '%s', error);
|
log.error('config', '%s', error);
|
||||||
return false;
|
throw new ConfigError(
|
||||||
|
`Unable to read configuration file at ${path}: ${error}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +169,7 @@ async function loadConfigFile(path: string): Promise<unknown> {
|
|||||||
log.error('config', ` - ${error.toString()}`);
|
log.error('config', ` - ${error.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
throw new ConfigError(`Cannot parse configuration file at ${path}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (configYaml.warnings.length > 0) {
|
if (configYaml.warnings.length > 0) {
|
||||||
@@ -109,7 +187,7 @@ async function loadConfigFile(path: string): Promise<unknown> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
log.error('config', 'Error reading configuration file at %s', path);
|
log.error('config', 'Error reading configuration file at %s', path);
|
||||||
log.error('config', '%s', e);
|
log.error('config', '%s', e);
|
||||||
return false;
|
throw new ConfigError(`Error reading configuration file at ${path}: ${e}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,14 +195,14 @@ export function validateConfig(config: unknown) {
|
|||||||
log.debug('config', 'Validating Headplane configuration');
|
log.debug('config', 'Validating Headplane configuration');
|
||||||
const result = headplaneConfig(config);
|
const result = headplaneConfig(config);
|
||||||
if (result instanceof type.errors) {
|
if (result instanceof type.errors) {
|
||||||
log.error('config', 'Error validating Headplane configuration:');
|
const errorMessages = [];
|
||||||
for (const [number, error] of result.entries()) {
|
for (const [number, error] of result.entries()) {
|
||||||
log.error('config', ` - (${number}): ${error.toString()}`);
|
const errorMsg = error.toString();
|
||||||
|
log.error('config', ` - (${number}): ${errorMsg}`);
|
||||||
|
errorMessages.push(errorMsg);
|
||||||
}
|
}
|
||||||
|
throw new ConfigError(errorMessages.join('\n'));
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+116
-17
@@ -19,8 +19,36 @@ const stringToBool = type('string | boolean').pipe((v) => {
|
|||||||
const serverConfig = type({
|
const serverConfig = type({
|
||||||
host: 'string.ip',
|
host: 'string.ip',
|
||||||
port: type('string | number.integer').pipe((v) => Number(v)),
|
port: type('string | number.integer').pipe((v) => Number(v)),
|
||||||
cookie_secret: '32 <= string <= 32',
|
data_path: 'string = "/var/lib/headplane/"',
|
||||||
|
cookie_secret: '(32 <= string <= 32)?',
|
||||||
|
cookie_secret_path: 'string?',
|
||||||
cookie_secure: stringToBool,
|
cookie_secure: stringToBool,
|
||||||
|
})
|
||||||
|
.narrow((obj: Record<string, unknown>, ctx: any) => {
|
||||||
|
const hasVal = obj.cookie_secret != null && `${obj.cookie_secret}` !== '';
|
||||||
|
const hasPath =
|
||||||
|
obj.cookie_secret_path != null && obj.cookie_secret_path !== '';
|
||||||
|
if (hasVal && hasPath)
|
||||||
|
return ctx.reject(
|
||||||
|
`Only one of "cookie_secret" or "cookie_secret_path" may be set.`,
|
||||||
|
);
|
||||||
|
if (!hasVal && !hasPath)
|
||||||
|
return ctx.reject(
|
||||||
|
`Either "cookie_secret" or "cookie_secret_path" must be provided for cookie_secret.`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.onDeepUndeclaredKey('reject');
|
||||||
|
|
||||||
|
const partialServerConfig = type({
|
||||||
|
host: 'string.ip?',
|
||||||
|
port: type('string | number.integer')
|
||||||
|
.pipe((v) => Number(v))
|
||||||
|
.optional(),
|
||||||
|
data_path: 'string = "/var/lib/headplane/"',
|
||||||
|
cookie_secret: '32 <= string <= 32?',
|
||||||
|
cookie_secret_path: 'string?',
|
||||||
|
cookie_secure: stringToBool.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const oidcConfig = type({
|
const oidcConfig = type({
|
||||||
@@ -33,9 +61,53 @@ const oidcConfig = type({
|
|||||||
redirect_uri: 'string.url?',
|
redirect_uri: 'string.url?',
|
||||||
user_storage_file: 'string = "/var/lib/headplane/users.json"',
|
user_storage_file: 'string = "/var/lib/headplane/users.json"',
|
||||||
disable_api_key_login: stringToBool,
|
disable_api_key_login: stringToBool,
|
||||||
headscale_api_key: 'string',
|
headscale_api_key: 'string?',
|
||||||
|
headscale_api_key_path: 'string?',
|
||||||
|
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
||||||
strict_validation: stringToBool.default(true),
|
strict_validation: stringToBool.default(true),
|
||||||
}).onDeepUndeclaredKey('reject');
|
scope: 'string = "openid email profile"',
|
||||||
|
extra_params: 'Record<string, string>?',
|
||||||
|
authorization_endpoint: 'string.url?',
|
||||||
|
token_endpoint: 'string.url?',
|
||||||
|
userinfo_endpoint: 'string.url?',
|
||||||
|
})
|
||||||
|
.narrow((obj: Record<string, unknown>, ctx: any) => {
|
||||||
|
const hasVal =
|
||||||
|
obj.headscale_api_key != null && `${obj.headscale_api_key}` !== '';
|
||||||
|
const hasPath =
|
||||||
|
obj.headscale_api_key_path != null && obj.headscale_api_key_path !== '';
|
||||||
|
if (hasVal && hasPath)
|
||||||
|
return ctx.reject(
|
||||||
|
`Only one of "headscale_api_key" or "headscale_api_key_path" may be set.`,
|
||||||
|
);
|
||||||
|
if (!hasVal && !hasPath)
|
||||||
|
return ctx.reject(
|
||||||
|
`Either "headscale_api_key" or "headscale_api_key_path" must be provided.`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.onDeepUndeclaredKey('reject');
|
||||||
|
|
||||||
|
const partialOidcConfig = type({
|
||||||
|
issuer: 'string.url?',
|
||||||
|
client_id: 'string?',
|
||||||
|
client_secret: 'string?',
|
||||||
|
client_secret_path: 'string?',
|
||||||
|
token_endpoint_auth_method:
|
||||||
|
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
||||||
|
redirect_uri: 'string.url?',
|
||||||
|
user_storage_file: 'string?',
|
||||||
|
disable_api_key_login: stringToBool.optional(),
|
||||||
|
headscale_api_key: 'string?',
|
||||||
|
headscale_api_key_path: 'string?',
|
||||||
|
profile_picture_source: '("oidc" | "gravatar")?',
|
||||||
|
strict_validation: stringToBool.default(true),
|
||||||
|
scope: 'string?',
|
||||||
|
extra_params: 'Record<string, string>?',
|
||||||
|
authorization_endpoint: 'string.url?',
|
||||||
|
token_endpoint: 'string.url?',
|
||||||
|
userinfo_endpoint: 'string.url?',
|
||||||
|
});
|
||||||
|
|
||||||
const headscaleConfig = type({
|
const headscaleConfig = type({
|
||||||
url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)),
|
url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)),
|
||||||
@@ -46,26 +118,53 @@ const headscaleConfig = type({
|
|||||||
dns_records_path: 'string?',
|
dns_records_path: 'string?',
|
||||||
}).onDeepUndeclaredKey('reject');
|
}).onDeepUndeclaredKey('reject');
|
||||||
|
|
||||||
|
const partialHeadscaleConfig = type({
|
||||||
|
url: type('string.url')
|
||||||
|
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
|
||||||
|
.optional(),
|
||||||
|
tls_cert_path: 'string?',
|
||||||
|
public_url: 'string.url?',
|
||||||
|
config_path: 'string?',
|
||||||
|
config_strict: stringToBool.optional(),
|
||||||
|
dns_records_path: 'string?',
|
||||||
|
});
|
||||||
|
|
||||||
const agentConfig = type({
|
const agentConfig = type({
|
||||||
enabled: stringToBool.default(false),
|
enabled: stringToBool.default(false),
|
||||||
host_name: 'string = "headplane-agent"',
|
host_name: 'string = "headplane-agent"',
|
||||||
pre_authkey: 'string = ""',
|
pre_authkey: 'string?',
|
||||||
|
pre_authkey_path: 'string?',
|
||||||
|
cache_ttl: 'number.integer = 180000',
|
||||||
|
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
|
||||||
|
executable_path: 'string = "/usr/libexec/headplane/agent"',
|
||||||
|
work_dir: 'string = "/var/lib/headplane/agent"',
|
||||||
|
})
|
||||||
|
.narrow((obj: Record<string, unknown>, ctx: any) => {
|
||||||
|
const hasVal = obj.pre_authkey != null && `${obj.pre_authkey}` !== '';
|
||||||
|
const hasPath = obj.pre_authkey_path != null && obj.pre_authkey_path !== '';
|
||||||
|
if (hasVal && hasPath)
|
||||||
|
return ctx.reject(
|
||||||
|
`Only one of "pre_authkey" or "pre_authkey_path" may be set.`,
|
||||||
|
);
|
||||||
|
if (!hasVal && !hasPath)
|
||||||
|
return ctx.reject(
|
||||||
|
`Either "pre_authkey" or "pre_authkey_path" must be provided.`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.onDeepUndeclaredKey('reject');
|
||||||
|
|
||||||
|
const partialAgentConfig = type({
|
||||||
|
enabled: stringToBool.default(false),
|
||||||
|
host_name: 'string = "headplane-agent"',
|
||||||
|
pre_authkey: 'string?',
|
||||||
|
pre_authkey_path: 'string?',
|
||||||
cache_ttl: 'number.integer = 180000',
|
cache_ttl: 'number.integer = 180000',
|
||||||
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
|
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
|
||||||
executable_path: 'string = "/usr/libexec/headplane/agent"',
|
executable_path: 'string = "/usr/libexec/headplane/agent"',
|
||||||
work_dir: 'string = "/var/lib/headplane/agent"',
|
work_dir: 'string = "/var/lib/headplane/agent"',
|
||||||
});
|
});
|
||||||
|
|
||||||
const partialAgentConfig = type({
|
|
||||||
enabled: stringToBool,
|
|
||||||
host_name: 'string | undefined',
|
|
||||||
pre_authkey: 'string | undefined',
|
|
||||||
cache_ttl: 'number.integer | undefined',
|
|
||||||
cache_path: 'string | undefined',
|
|
||||||
executable_path: 'string | undefined',
|
|
||||||
work_dir: 'string | undefined',
|
|
||||||
}).partial();
|
|
||||||
|
|
||||||
const dockerConfig = type({
|
const dockerConfig = type({
|
||||||
enabled: stringToBool,
|
enabled: stringToBool,
|
||||||
container_name: 'string = ""',
|
container_name: 'string = ""',
|
||||||
@@ -114,10 +213,10 @@ export const headplaneConfig = type({
|
|||||||
|
|
||||||
export const partialHeadplaneConfig = type({
|
export const partialHeadplaneConfig = type({
|
||||||
debug: stringToBool,
|
debug: stringToBool,
|
||||||
server: serverConfig.partial(),
|
server: partialServerConfig,
|
||||||
'oidc?': oidcConfig.partial(),
|
'oidc?': partialOidcConfig,
|
||||||
'integration?': partialIntegrationConfig,
|
'integration?': partialIntegrationConfig,
|
||||||
headscale: headscaleConfig.partial(),
|
headscale: partialHeadscaleConfig,
|
||||||
}).partial();
|
}).partial();
|
||||||
|
|
||||||
export type HeadplaneConfig = typeof headplaneConfig.infer;
|
export type HeadplaneConfig = typeof headplaneConfig.infer;
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { mkdir } from 'node:fs/promises';
|
||||||
|
import { dirname, resolve } from 'node:path';
|
||||||
|
import { migrate } from 'drizzle-orm/libsql/migrator';
|
||||||
|
import { drizzle } from 'drizzle-orm/libsql/sqlite3';
|
||||||
|
import log from '~/utils/log';
|
||||||
|
|
||||||
|
export async function createDbClient(path: string) {
|
||||||
|
const realPath = resolve(path);
|
||||||
|
try {
|
||||||
|
await mkdir(dirname(realPath), { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
'server',
|
||||||
|
'Failed to create directory for database at %s: %s',
|
||||||
|
realPath,
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
throw new Error(`Could not create directory for database at ${realPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn the path into a URL with the file protocol
|
||||||
|
const db = drizzle(`file://${realPath}`);
|
||||||
|
migrate(db, {
|
||||||
|
migrationsFolder: './drizzle',
|
||||||
|
});
|
||||||
|
|
||||||
|
return db;
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { eq, isNotNull } from 'drizzle-orm';
|
||||||
|
import { LoaderFunctionArgs } from 'react-router';
|
||||||
|
import { Machine } from '~/types';
|
||||||
|
import log from '~/utils/log';
|
||||||
|
import { LoadContext } from '..';
|
||||||
|
import { ephemeralNodes } from './schema';
|
||||||
|
|
||||||
|
export async function pruneEphemeralNodes({
|
||||||
|
context,
|
||||||
|
request,
|
||||||
|
}: LoaderFunctionArgs<LoadContext>) {
|
||||||
|
const session = await context.sessions.auth(request);
|
||||||
|
const ephemerals = await context.db
|
||||||
|
.select()
|
||||||
|
.from(ephemeralNodes)
|
||||||
|
.where(isNotNull(ephemeralNodes.node_key));
|
||||||
|
|
||||||
|
if (ephemerals.length === 0) {
|
||||||
|
log.debug('api', 'No ephemeral nodes to prune');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||||
|
'v1/node',
|
||||||
|
session.api_key,
|
||||||
|
);
|
||||||
|
|
||||||
|
const toPrune = nodes.filter((node) => {
|
||||||
|
if (node.online) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (toPrune.length === 0) {
|
||||||
|
log.debug('api', 'No SSH nodes to prune');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from the Headscale nodes list and then from the database
|
||||||
|
const promises = toPrune.map((node) => {
|
||||||
|
return async () => {
|
||||||
|
log.debug('api', `Pruning node ${node.name}`);
|
||||||
|
await context.client.delete(`v1/node/${node.id}`, session.api_key);
|
||||||
|
|
||||||
|
await context.db
|
||||||
|
.delete(ephemeralNodes)
|
||||||
|
.where(eq(ephemeralNodes.node_key, node.nodeKey));
|
||||||
|
log.debug('api', `Node ${node.name} pruned successfully`);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all(promises.map((p) => p()));
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||||
|
import { HostInfo } from '~/types';
|
||||||
|
|
||||||
|
export const ephemeralNodes = sqliteTable('ephemeral_nodes', {
|
||||||
|
auth_key: text('auth_key').primaryKey(),
|
||||||
|
node_key: text('node_key'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type EphemeralNode = typeof ephemeralNodes.$inferSelect;
|
||||||
|
export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert;
|
||||||
|
|
||||||
|
export const hostInfo = sqliteTable('host_info', {
|
||||||
|
host_id: text('host_id').primaryKey(),
|
||||||
|
payload: text('payload', { mode: 'json' }).$type<HostInfo>(),
|
||||||
|
updated_at: integer('updated_at', { mode: 'timestamp' }).$default(
|
||||||
|
() => new Date(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type HostInfoRecord = typeof hostInfo.$inferSelect;
|
||||||
|
export type HostInfoInsert = typeof hostInfo.$inferInsert;
|
||||||
|
|
||||||
|
export const users = sqliteTable('users', {
|
||||||
|
id: text('id').primaryKey(),
|
||||||
|
sub: text('sub').notNull().unique(),
|
||||||
|
caps: integer('caps').notNull().default(0),
|
||||||
|
onboarded: integer('onboarded', { mode: 'boolean' }).notNull().default(false),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type User = typeof users.$inferSelect;
|
||||||
|
export type UserInsert = typeof users.$inferInsert;
|
||||||
@@ -14,7 +14,7 @@ const databaseConfig = type({
|
|||||||
type: '"sqlite" | "sqlite3"',
|
type: '"sqlite" | "sqlite3"',
|
||||||
sqlite: {
|
sqlite: {
|
||||||
path: 'string',
|
path: 'string',
|
||||||
write_head_log: goBool.default(true),
|
write_ahead_log: goBool.default(true),
|
||||||
wal_autocheckpoint: 'number = 1000',
|
wal_autocheckpoint: 'number = 1000',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -103,6 +103,7 @@ export const headscaleConfig = type({
|
|||||||
dns: {
|
dns: {
|
||||||
magic_dns: goBool.default(true),
|
magic_dns: goBool.default(true),
|
||||||
base_domain: 'string = "headscale.net"',
|
base_domain: 'string = "headscale.net"',
|
||||||
|
override_local_dns: goBool.default(false),
|
||||||
nameservers: type({
|
nameservers: type({
|
||||||
global: type('string[]').default(() => []),
|
global: type('string[]').default(() => []),
|
||||||
split: type('Record<string, string[]>').default(() => ({})),
|
split: type('Record<string, string[]>').default(() => ({})),
|
||||||
|
|||||||
@@ -0,0 +1,420 @@
|
|||||||
|
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
|
||||||
|
import EventEmitter from 'node:events';
|
||||||
|
import { access, constants, mkdir, open } from 'node:fs/promises';
|
||||||
|
import { getegid, geteuid } from 'node:process';
|
||||||
|
import { createInterface, Interface } from 'node:readline';
|
||||||
|
import { inArray } from 'drizzle-orm';
|
||||||
|
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver-core';
|
||||||
|
import { HostInfo } from '~/types';
|
||||||
|
import log from '~/utils/log';
|
||||||
|
import { HeadplaneConfig } from './config/schema';
|
||||||
|
import { hostInfo } from './db/schema';
|
||||||
|
|
||||||
|
export async function createHeadplaneAgent(
|
||||||
|
config: NonNullable<HeadplaneConfig['integration']>['agent'] | undefined,
|
||||||
|
headscaleUrl: string,
|
||||||
|
db: LibSQLDatabase,
|
||||||
|
) {
|
||||||
|
if (!config?.enabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!config.pre_authkey) {
|
||||||
|
log.error('agent', 'Agent `pre_authkey` is not set');
|
||||||
|
log.warn('agent', 'The agent will not run until resolved');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await access(config.work_dir, constants.R_OK | constants.W_OK);
|
||||||
|
log.debug('config', 'Using agent work dir at %s', config.work_dir);
|
||||||
|
} catch (error) {
|
||||||
|
// Try to create the directory just in case
|
||||||
|
try {
|
||||||
|
await mkdir(config.work_dir, { recursive: true });
|
||||||
|
log.debug('config', 'Created agent work dir at %s', config.work_dir);
|
||||||
|
log.info(
|
||||||
|
'config',
|
||||||
|
'Created missing agent work dir at %s',
|
||||||
|
config.work_dir,
|
||||||
|
);
|
||||||
|
|
||||||
|
return;
|
||||||
|
} catch (innerError) {
|
||||||
|
log.error(
|
||||||
|
'config',
|
||||||
|
'Failed to create agent work dir at %s',
|
||||||
|
config.work_dir,
|
||||||
|
);
|
||||||
|
log.info(
|
||||||
|
'config',
|
||||||
|
'Agent work dir not accessible at %s',
|
||||||
|
config.work_dir,
|
||||||
|
);
|
||||||
|
log.debug('config', 'Error details: %s', error);
|
||||||
|
log.debug('config', 'Create error details: %s', innerError);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const handle = await open(config.cache_path, 'a+');
|
||||||
|
log.info('agent', 'Using agent cache file at %s', config.cache_path);
|
||||||
|
await handle.close();
|
||||||
|
} catch (error) {
|
||||||
|
log.info(
|
||||||
|
'agent',
|
||||||
|
'Agent cache file not accessible at %s',
|
||||||
|
config.cache_path,
|
||||||
|
);
|
||||||
|
log.debug('agent', 'Error details: %s', error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = new HeadplaneAgent({
|
||||||
|
...config,
|
||||||
|
headscaleUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.on('spawn', () => {
|
||||||
|
log.info('agent', 'Headplane agent started');
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.on('ready', () => {
|
||||||
|
log.info('agent', 'Headplane agent is ready and serving queries');
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.on('error', (err) => {
|
||||||
|
log.warn('agent', 'Headplane agent experienced an error: %s', err.message);
|
||||||
|
log.debug('agent', 'Error details: %o', err);
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.on('exit', ({ code, signal }) => {
|
||||||
|
log.warn(
|
||||||
|
'agent',
|
||||||
|
'Headplane agent exited with code %s and signal %s',
|
||||||
|
code,
|
||||||
|
signal,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.on('restart', ({ delay, attempt }) => {
|
||||||
|
log.warn(
|
||||||
|
'agent',
|
||||||
|
'Headplane agent will restart in %f seconds (attempt %d)',
|
||||||
|
delay / 1000,
|
||||||
|
attempt,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.on('stderr', (data) => {
|
||||||
|
log.error('agent', 'Headplane agent stderr:', data);
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.on('info', async ({ id, info }) => {
|
||||||
|
log.debug('agent', 'Received HostInfo for %s', id);
|
||||||
|
try {
|
||||||
|
const parsedInfo = JSON.parse(info) as HostInfo;
|
||||||
|
await db
|
||||||
|
.insert(hostInfo)
|
||||||
|
.values({
|
||||||
|
host_id: id,
|
||||||
|
payload: parsedInfo,
|
||||||
|
updated_at: new Date(),
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: hostInfo.host_id,
|
||||||
|
set: {
|
||||||
|
payload: parsedInfo,
|
||||||
|
updated_at: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
log.error(
|
||||||
|
'agent',
|
||||||
|
'Failed to parse HostInfo for %s: %s',
|
||||||
|
id,
|
||||||
|
error instanceof Error ? error.message : String(error),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
agent.start();
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => agent.shutdown());
|
||||||
|
process.on('SIGINT', () => agent.shutdown());
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentID: () => agent.agentID(),
|
||||||
|
lookup: async (nodes: string[]) => {
|
||||||
|
const results = await db
|
||||||
|
.select()
|
||||||
|
.from(hostInfo)
|
||||||
|
.where(inArray(hostInfo.host_id, nodes));
|
||||||
|
|
||||||
|
return Object.fromEntries(
|
||||||
|
results.filter((r) => r.payload).map((r) => [r.host_id, r.payload]),
|
||||||
|
) as Record<string, HostInfo>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type AgentOptions = NonNullable<
|
||||||
|
NonNullable<HeadplaneConfig['integration']>['agent']
|
||||||
|
> & {
|
||||||
|
headscaleUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AgentEvents {
|
||||||
|
ready: [];
|
||||||
|
spawn: [];
|
||||||
|
error: [Error];
|
||||||
|
exit: [{ code?: number; signal?: NodeJS.Signals }];
|
||||||
|
restart: [{ delay: number; attempt: number }];
|
||||||
|
stderr: [string];
|
||||||
|
info: [{ id: string; info: string }];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A custom class that turns the lifecycle of the agent into an event emitter.
|
||||||
|
* It has many different responsibilities ensuring that:
|
||||||
|
* - The agent is spawned with the correct configuration
|
||||||
|
* - The agent is ready and still running (ping/heartbeat)
|
||||||
|
* - The agent is restarted on a backoff strategy
|
||||||
|
*/
|
||||||
|
class HeadplaneAgent extends EventEmitter<AgentEvents> {
|
||||||
|
private child?: ChildProcessWithoutNullStreams;
|
||||||
|
private readline?: Interface;
|
||||||
|
|
||||||
|
private options: AgentOptions;
|
||||||
|
|
||||||
|
private hbInterval?: NodeJS.Timeout;
|
||||||
|
private hbDeadline?: NodeJS.Timeout;
|
||||||
|
private restartTimer?: NodeJS.Timeout;
|
||||||
|
private isWaitingForAck = false;
|
||||||
|
private isShuttingDown = false;
|
||||||
|
private backoffAttempt = 0;
|
||||||
|
private agentId?: string;
|
||||||
|
|
||||||
|
private BASE_BACKOFF_MS = 1.5 * 1000; // 1.5 seconds
|
||||||
|
private MAX_BACKOFF_MS = 30 * 1000; // 30 seconds
|
||||||
|
private PROBE_COOLDOWN_MS = 5 * 60_000; // 5 minutes
|
||||||
|
private PROBE_ATTEMPT_INTERVAL = 10; // Every 10th attempt
|
||||||
|
|
||||||
|
private HEARTBEAT_INTERVAL_MS = 5 * 1000; // 5 seconds
|
||||||
|
private HEARTBEAT_TIMEOUT_MS = 3 * 1000; // 3 seconds
|
||||||
|
|
||||||
|
constructor(options: AgentOptions) {
|
||||||
|
super();
|
||||||
|
this.options = options;
|
||||||
|
}
|
||||||
|
|
||||||
|
agentID() {
|
||||||
|
return this.agentId;
|
||||||
|
}
|
||||||
|
|
||||||
|
start() {
|
||||||
|
this.isShuttingDown = false;
|
||||||
|
this.spawnInternalChild();
|
||||||
|
}
|
||||||
|
|
||||||
|
shutdown() {
|
||||||
|
this.isShuttingDown = true;
|
||||||
|
this.agentId = undefined;
|
||||||
|
|
||||||
|
clearTimeout(this.restartTimer);
|
||||||
|
clearInterval(this.hbInterval);
|
||||||
|
clearTimeout(this.hbDeadline);
|
||||||
|
this.isWaitingForAck = false;
|
||||||
|
|
||||||
|
this.send('SHUTDOWN');
|
||||||
|
this.child?.kill('SIGTERM');
|
||||||
|
this.readline?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private spawnInternalChild() {
|
||||||
|
this.child = spawn(this.options.executable_path, {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
uid: geteuid?.() ?? undefined,
|
||||||
|
gid: getegid?.() ?? undefined,
|
||||||
|
env: {
|
||||||
|
HOME: process.env.HOME,
|
||||||
|
HEADPLANE_AGENT_WORK_DIR: this.options.work_dir,
|
||||||
|
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? 'true' : 'false',
|
||||||
|
HEADPLANE_AGENT_HOSTNAME: this.options.host_name,
|
||||||
|
HEADPLANE_AGENT_TS_SERVER: this.options.headscaleUrl,
|
||||||
|
HEADPLANE_AGENT_TS_AUTHKEY: this.options.pre_authkey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.emit('spawn');
|
||||||
|
this.child.on('error', (err) => this.emit('error', err));
|
||||||
|
this.child.stderr.on('data', (data) =>
|
||||||
|
this.emit('stderr', data.toString()),
|
||||||
|
);
|
||||||
|
|
||||||
|
this.child.on('exit', (code, signal) => {
|
||||||
|
this.agentId = undefined;
|
||||||
|
this.emit('exit', {
|
||||||
|
code: code ?? undefined,
|
||||||
|
signal: signal ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.readline?.close();
|
||||||
|
clearInterval(this.hbInterval);
|
||||||
|
clearTimeout(this.hbDeadline);
|
||||||
|
this.isWaitingForAck = false;
|
||||||
|
|
||||||
|
if (this.isShuttingDown) {
|
||||||
|
log.info('agent', 'Child process exited gracefully');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.backoffAttempt++;
|
||||||
|
const delay = this.calculateBackoff();
|
||||||
|
this.emit('restart', { delay, attempt: this.backoffAttempt });
|
||||||
|
this.restartTimer = setTimeout(() => this.spawnInternalChild(), delay);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.readline = createInterface({ input: this.child.stdout });
|
||||||
|
this.readline.on('line', (line) => this.readlineHandler(line));
|
||||||
|
this.send('START');
|
||||||
|
|
||||||
|
// Start the heartbeat loop with our custom interval
|
||||||
|
this.hbInterval = setInterval(() => {
|
||||||
|
if (!this.child || this.child.killed) return;
|
||||||
|
|
||||||
|
// If we get here, we missed the last PONG response and can die
|
||||||
|
if (this.isWaitingForAck) {
|
||||||
|
this.agentId = undefined;
|
||||||
|
this.emit('error', new Error('Agent heartbeat missed'));
|
||||||
|
this.child.kill('SIGTERM');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.isWaitingForAck = true;
|
||||||
|
this.send('PING');
|
||||||
|
|
||||||
|
clearTimeout(this.hbDeadline);
|
||||||
|
this.hbDeadline = setTimeout(() => {
|
||||||
|
if (this.isWaitingForAck) {
|
||||||
|
this.agentId = undefined;
|
||||||
|
this.emit('error', new Error('Agent heartbeat timeout'));
|
||||||
|
this.child?.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
}, this.HEARTBEAT_TIMEOUT_MS);
|
||||||
|
}, this.HEARTBEAT_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
private send(s: string) {
|
||||||
|
if (!this.child || this.child.killed) return;
|
||||||
|
const ok = this.child.stdin.write(`${s}\n`);
|
||||||
|
if (!ok) this.child.stdin.once('drain', () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates a backoff time based on the current attempt.
|
||||||
|
* Supports a randomized jitter to avoid thundering herd problems.
|
||||||
|
*
|
||||||
|
* @param min The minimum backoff time in milliseconds.
|
||||||
|
* @param max The maximum backoff time in milliseconds.
|
||||||
|
* @returns The calculated backoff time in milliseconds.
|
||||||
|
*/
|
||||||
|
private calculateBackoff() {
|
||||||
|
const attempt = this.backoffAttempt;
|
||||||
|
if (attempt > 0 && attempt % this.PROBE_ATTEMPT_INTERVAL === 0) {
|
||||||
|
const jitter = Math.floor(Math.random() * (this.MAX_BACKOFF_MS + 1));
|
||||||
|
const sign = Math.random() < 0.5 ? -1 : 1;
|
||||||
|
|
||||||
|
return Math.max(0, this.PROBE_COOLDOWN_MS + jitter * sign);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cap = Math.min(
|
||||||
|
this.MAX_BACKOFF_MS,
|
||||||
|
this.BASE_BACKOFF_MS * 2 ** attempt,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Math.floor(Math.random() * (cap + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes and dispatches the appropriate response based on the message.
|
||||||
|
* @param line The message to process (piped straight from readline)
|
||||||
|
*/
|
||||||
|
private readlineHandler(line: string) {
|
||||||
|
// When we are ready we force a refresh so that the UI has the most
|
||||||
|
// up-to-date information and will gracefully handle new info being sent
|
||||||
|
if (line.startsWith('READY')) {
|
||||||
|
this.backoffAttempt = 0;
|
||||||
|
this.send('REFRESH');
|
||||||
|
this.emit('ready');
|
||||||
|
|
||||||
|
const agentId = line.slice(5).trim();
|
||||||
|
if (this.agentId && this.agentId !== agentId) {
|
||||||
|
log.warn(
|
||||||
|
'agent',
|
||||||
|
'Agent ID changed from %s to %s',
|
||||||
|
this.agentId,
|
||||||
|
agentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.agentId = agentId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('PONG')) {
|
||||||
|
this.isWaitingForAck = false;
|
||||||
|
clearTimeout(this.hbDeadline);
|
||||||
|
|
||||||
|
const agentId = line.slice(5).trim();
|
||||||
|
if (this.agentId && this.agentId !== agentId) {
|
||||||
|
log.warn(
|
||||||
|
'agent',
|
||||||
|
'Agent ID changed from %s to %s',
|
||||||
|
this.agentId,
|
||||||
|
agentId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.agentId = agentId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('HOSTINFO')) {
|
||||||
|
const data = line.slice(9).trim();
|
||||||
|
const [id, ...infoParts] = data.split(' ');
|
||||||
|
const info = infoParts.join(' ');
|
||||||
|
this.emit('info', { id, info });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('ERROR')) {
|
||||||
|
const error = line.slice(6).trim();
|
||||||
|
this.emit('error', new Error(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith('LOG')) {
|
||||||
|
const logSnippet = line.slice(4).trim();
|
||||||
|
const [level, ...messageParts] = logSnippet.split(' ');
|
||||||
|
const message = messageParts.join(' ');
|
||||||
|
switch (level) {
|
||||||
|
case 'INFO':
|
||||||
|
log.info('agent', message);
|
||||||
|
break;
|
||||||
|
case 'WARN':
|
||||||
|
log.warn('agent', message);
|
||||||
|
break;
|
||||||
|
case 'ERROR':
|
||||||
|
log.error('agent', message);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
log.debug('agent', message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+45
-15
@@ -1,14 +1,15 @@
|
|||||||
|
import { join } from 'node:path';
|
||||||
import { env, versions } from 'node:process';
|
import { env, versions } from 'node:process';
|
||||||
import { createHonoServer } from 'react-router-hono-server/node';
|
import { createHonoServer } from 'react-router-hono-server/node';
|
||||||
|
|
||||||
import log from '~/utils/log';
|
import log from '~/utils/log';
|
||||||
import { configureConfig, configureLogger, envVariables } from './config/env';
|
import { configureConfig, configureLogger, envVariables } from './config/env';
|
||||||
import { loadIntegration } from './config/integration';
|
import { loadIntegration } from './config/integration';
|
||||||
import { loadConfig } from './config/loader';
|
import { loadConfig } from './config/loader';
|
||||||
|
import { createDbClient } from './db/client.server';
|
||||||
import { createApiClient } from './headscale/api-client';
|
import { createApiClient } from './headscale/api-client';
|
||||||
import { loadHeadscaleConfig } from './headscale/config-loader';
|
import { loadHeadscaleConfig } from './headscale/config-loader';
|
||||||
import { loadAgentSocket } from './web/agent';
|
import { createHeadplaneAgent } from './hp-agent';
|
||||||
import { createOidcClient } from './web/oidc';
|
import { configureOidcAuth } from './web/oidc';
|
||||||
import { createSessionStorage } from './web/sessions';
|
import { createSessionStorage } from './web/sessions';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
@@ -28,6 +29,13 @@ const config = await loadConfig(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const db = await createDbClient(join(config.server.data_path, 'hp_persist.db'));
|
||||||
|
const agents = await createHeadplaneAgent(
|
||||||
|
config.integration?.agent,
|
||||||
|
config.headscale.url,
|
||||||
|
db,
|
||||||
|
);
|
||||||
|
|
||||||
// We also use this file to load anything needed by the react router code.
|
// We also use this file to load anything needed by the react router code.
|
||||||
// These are usually per-request things that we need access to, like the
|
// These are usually per-request things that we need access to, like the
|
||||||
// helper that can issue and revoke cookies.
|
// helper that can issue and revoke cookies.
|
||||||
@@ -41,27 +49,27 @@ const appLoadContext = {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// TODO: Better cookie options in config
|
// TODO: Better cookie options in config
|
||||||
sessions: await createSessionStorage(
|
sessions: await createSessionStorage({
|
||||||
{
|
secret: config.server.cookie_secret,
|
||||||
name: '_hp_session',
|
db,
|
||||||
maxAge: 60 * 60 * 24, // 24 hours
|
oidcUsersFile: config.oidc?.user_storage_file,
|
||||||
|
cookie: {
|
||||||
|
name: '_hp_auth',
|
||||||
secure: config.server.cookie_secure,
|
secure: config.server.cookie_secure,
|
||||||
secrets: [config.server.cookie_secret],
|
maxAge: 60 * 60 * 24, // 24 hours
|
||||||
|
// domain: config.server.cookie_domain,
|
||||||
},
|
},
|
||||||
config.oidc?.user_storage_file,
|
}),
|
||||||
),
|
|
||||||
|
|
||||||
client: await createApiClient(
|
client: await createApiClient(
|
||||||
config.headscale.url,
|
config.headscale.url,
|
||||||
config.headscale.tls_cert_path,
|
config.headscale.tls_cert_path,
|
||||||
),
|
),
|
||||||
|
|
||||||
agents: await loadAgentSocket(
|
agents,
|
||||||
config.integration?.agent,
|
|
||||||
config.headscale.url,
|
|
||||||
),
|
|
||||||
integration: await loadIntegration(config.integration),
|
integration: await loadIntegration(config.integration),
|
||||||
oidc: config.oidc ? await createOidcClient(config.oidc) : undefined,
|
oidc: config.oidc ? await configureOidcAuth(config.oidc) : undefined,
|
||||||
|
db,
|
||||||
};
|
};
|
||||||
|
|
||||||
declare module 'react-router' {
|
declare module 'react-router' {
|
||||||
@@ -72,6 +80,18 @@ export default createHonoServer({
|
|||||||
overrideGlobalObjects: true,
|
overrideGlobalObjects: true,
|
||||||
port: config.server.port,
|
port: config.server.port,
|
||||||
hostname: config.server.host,
|
hostname: config.server.host,
|
||||||
|
beforeAll: async (app) => {
|
||||||
|
app.use(__PREFIX__, async (c) => {
|
||||||
|
return c.redirect(`${__PREFIX__}/`);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
serveStaticOptions: {
|
||||||
|
clientAssets: {
|
||||||
|
// This is part of our monkey-patch for react-router-hono-server
|
||||||
|
// To see the first part, go to the patches/ directory.
|
||||||
|
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ''),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// Only log in development mode
|
// Only log in development mode
|
||||||
defaultLogger: import.meta.env.DEV,
|
defaultLogger: import.meta.env.DEV,
|
||||||
@@ -86,3 +106,13 @@ export default createHonoServer({
|
|||||||
log.info('server', 'Running on %s:%s', info.address, info.port);
|
log.info('server', 'Running on %s:%s', info.address, info.port);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
log.info('server', 'Received SIGINT, shutting down...');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
log.info('server', 'Received SIGTERM, shutting down...');
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,441 +0,0 @@
|
|||||||
import { ChildProcess, spawn } from 'node:child_process';
|
|
||||||
import { createHash } from 'node:crypto';
|
|
||||||
import {
|
|
||||||
constants,
|
|
||||||
access,
|
|
||||||
mkdir,
|
|
||||||
open,
|
|
||||||
readFile,
|
|
||||||
writeFile,
|
|
||||||
} from 'node:fs/promises';
|
|
||||||
import { exit } from 'node:process';
|
|
||||||
import { createInterface } from 'node:readline';
|
|
||||||
import { setTimeout } from 'node:timers/promises';
|
|
||||||
import { type } from 'arktype';
|
|
||||||
import { HostInfo } from '~/types';
|
|
||||||
import log from '~/utils/log';
|
|
||||||
import type { HeadplaneConfig } from '../config/schema';
|
|
||||||
|
|
||||||
interface LogResponse {
|
|
||||||
Level: 'info' | 'debug' | 'error' | 'fatal';
|
|
||||||
Message: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RegisterMessage {
|
|
||||||
Type: 'register';
|
|
||||||
ID: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface StatusMessage {
|
|
||||||
Type: 'status';
|
|
||||||
Data: Record<string, HostInfo>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MessageResponse {
|
|
||||||
Level: 'msg';
|
|
||||||
Message: RegisterMessage | StatusMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
type AgentResponse = LogResponse | MessageResponse;
|
|
||||||
|
|
||||||
export async function loadAgentSocket(
|
|
||||||
config: NonNullable<HeadplaneConfig['integration']>['agent'] | undefined,
|
|
||||||
headscaleUrl: string,
|
|
||||||
) {
|
|
||||||
if (!config?.enabled) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (config.pre_authkey.trim().length === 0) {
|
|
||||||
log.error('agent', 'Agent `pre_authkey` is not set');
|
|
||||||
log.warn('agent', 'The agent will not run until resolved');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await access(config.work_dir, constants.R_OK | constants.W_OK);
|
|
||||||
log.debug('config', 'Using agent work dir at %s', config.work_dir);
|
|
||||||
} catch (error) {
|
|
||||||
// Try to create the directory just in case
|
|
||||||
try {
|
|
||||||
await mkdir(config.work_dir, { recursive: true });
|
|
||||||
log.debug('config', 'Created agent work dir at %s', config.work_dir);
|
|
||||||
log.info(
|
|
||||||
'config',
|
|
||||||
'Created missing agent work dir at %s',
|
|
||||||
config.work_dir,
|
|
||||||
);
|
|
||||||
|
|
||||||
return;
|
|
||||||
} catch (innerError) {
|
|
||||||
log.error(
|
|
||||||
'config',
|
|
||||||
'Failed to create agent work dir at %s',
|
|
||||||
config.work_dir,
|
|
||||||
);
|
|
||||||
log.info(
|
|
||||||
'config',
|
|
||||||
'Agent work dir not accessible at %s',
|
|
||||||
config.work_dir,
|
|
||||||
);
|
|
||||||
log.debug('config', 'Error details: %s', error);
|
|
||||||
log.debug('config', 'Create error details: %s', innerError);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const handle = await open(config.cache_path, 'a+');
|
|
||||||
log.info('agent', 'Using agent cache file at %s', config.cache_path);
|
|
||||||
await handle.close();
|
|
||||||
} catch (error) {
|
|
||||||
log.info(
|
|
||||||
'agent',
|
|
||||||
'Agent cache file not accessible at %s',
|
|
||||||
config.cache_path,
|
|
||||||
);
|
|
||||||
log.debug('agent', 'Error details: %s', error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cache = new TimedCache<HostInfo>(config.cache_ttl, config.cache_path);
|
|
||||||
return new AgentManager(cache, config, headscaleUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
class AgentManager {
|
|
||||||
private static readonly MAX_RESTARTS = 5;
|
|
||||||
private restartCounter = 0;
|
|
||||||
|
|
||||||
private cache: TimedCache<HostInfo>;
|
|
||||||
private headscaleUrl: string;
|
|
||||||
private config: NonNullable<
|
|
||||||
NonNullable<HeadplaneConfig['integration']>['agent']
|
|
||||||
>;
|
|
||||||
|
|
||||||
private spawnProcess: ChildProcess | null;
|
|
||||||
private agentId: string | null;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
cache: TimedCache<HostInfo>,
|
|
||||||
config: NonNullable<NonNullable<HeadplaneConfig['integration']>['agent']>,
|
|
||||||
headscaleUrl: string,
|
|
||||||
) {
|
|
||||||
this.cache = cache;
|
|
||||||
this.config = config;
|
|
||||||
this.headscaleUrl = headscaleUrl;
|
|
||||||
this.spawnProcess = null;
|
|
||||||
this.agentId = null;
|
|
||||||
this.startAgent();
|
|
||||||
|
|
||||||
process.on('SIGINT', () => {
|
|
||||||
this.spawnProcess?.kill();
|
|
||||||
exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on('SIGTERM', () => {
|
|
||||||
this.spawnProcess?.kill();
|
|
||||||
exit(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Used by the UI to indicate why the agent is not running.
|
|
||||||
* Exhaustion requires a manual restart of the agent.
|
|
||||||
* (Which can be invoked via the UI)
|
|
||||||
* @returns true if the agent is exhausted
|
|
||||||
*/
|
|
||||||
exhausted() {
|
|
||||||
return this.restartCounter >= AgentManager.MAX_RESTARTS;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Called by the UI to manually force a restart of the agent.
|
|
||||||
*/
|
|
||||||
deExhaust() {
|
|
||||||
this.restartCounter = 0;
|
|
||||||
this.startAgent();
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
* Stored agent ID for the current process. This is caught by the agent
|
|
||||||
* when parsing the stdout on agent startup.
|
|
||||||
*/
|
|
||||||
agentID() {
|
|
||||||
return this.agentId;
|
|
||||||
}
|
|
||||||
|
|
||||||
private startAgent() {
|
|
||||||
if (this.spawnProcess) {
|
|
||||||
log.debug('agent', 'Agent already running');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.exhausted()) {
|
|
||||||
log.error('agent', 'Agent is exhausted, cannot start');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cannot be detached since we want to follow our process lifecycle
|
|
||||||
// We also need to be able to send data to the process by using stdin
|
|
||||||
log.info(
|
|
||||||
'agent',
|
|
||||||
'Starting agent process (attempt %d)',
|
|
||||||
this.restartCounter,
|
|
||||||
);
|
|
||||||
this.spawnProcess = spawn(this.config.executable_path, [], {
|
|
||||||
detached: false,
|
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
|
||||||
env: {
|
|
||||||
HOME: process.env.HOME,
|
|
||||||
HEADPLANE_EMBEDDED: 'true',
|
|
||||||
HEADPLANE_AGENT_WORK_DIR: this.config.work_dir,
|
|
||||||
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? 'true' : 'false',
|
|
||||||
HEADPLANE_AGENT_HOSTNAME: this.config.host_name,
|
|
||||||
HEADPLANE_AGENT_TS_SERVER: this.headscaleUrl,
|
|
||||||
HEADPLANE_AGENT_TS_AUTHKEY: this.config.pre_authkey,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!this.spawnProcess?.pid) {
|
|
||||||
log.error('agent', 'Failed to start agent process');
|
|
||||||
this.restartCounter++;
|
|
||||||
global.setTimeout(() => this.startAgent(), 1000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.spawnProcess.stdin === null || this.spawnProcess.stdout === null) {
|
|
||||||
log.error('agent', 'Failed to connect to agent process');
|
|
||||||
this.restartCounter++;
|
|
||||||
global.setTimeout(() => this.startAgent(), 1000);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rlStdout = createInterface({
|
|
||||||
input: this.spawnProcess.stdout,
|
|
||||||
crlfDelay: Number.POSITIVE_INFINITY,
|
|
||||||
});
|
|
||||||
|
|
||||||
rlStdout.on('line', (line) => {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(line) as AgentResponse;
|
|
||||||
if (parsed.Level === 'msg') {
|
|
||||||
switch (parsed.Message.Type) {
|
|
||||||
case 'register':
|
|
||||||
this.agentId = parsed.Message.ID;
|
|
||||||
break;
|
|
||||||
case 'status':
|
|
||||||
for (const [key, value] of Object.entries(parsed.Message.Data)) {
|
|
||||||
// Mark the agent as the one that is running
|
|
||||||
// We store it in the cache so that it shows
|
|
||||||
// itself later
|
|
||||||
if (key === this.agentId) {
|
|
||||||
value.HeadplaneAgent = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.cache.set(key, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (parsed.Level) {
|
|
||||||
case 'info':
|
|
||||||
case 'debug':
|
|
||||||
case 'error':
|
|
||||||
log[parsed.Level]('agent', parsed.Message);
|
|
||||||
break;
|
|
||||||
case 'fatal':
|
|
||||||
log.error('agent', parsed.Message);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
log.debug('agent', 'Unknown agent response: %s', line);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.debug('agent', 'Failed to parse agent response: %s', error);
|
|
||||||
log.debug('agent', 'Raw data: %s', line);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.spawnProcess.on('error', (error) => {
|
|
||||||
log.error('agent', 'Failed to start agent process: %s', error);
|
|
||||||
this.restartCounter++;
|
|
||||||
this.spawnProcess = null;
|
|
||||||
global.setTimeout(() => this.startAgent(), 1000);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.spawnProcess.on('exit', (code) => {
|
|
||||||
log.error('agent', 'Agent process exited with code %d', code ?? -1);
|
|
||||||
this.restartCounter++;
|
|
||||||
this.spawnProcess = null;
|
|
||||||
global.setTimeout(() => this.startAgent(), 1000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async lookup(nodeIds: string[]) {
|
|
||||||
const entries = this.cache.toJSON();
|
|
||||||
const missing = nodeIds.filter((nodeId) => !entries[nodeId]);
|
|
||||||
if (missing.length > 0) {
|
|
||||||
await this.requestData(missing);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Object.entries(entries).reduce<Record<string, HostInfo>>(
|
|
||||||
(acc, [key, value]) => {
|
|
||||||
if (nodeIds.includes(key)) {
|
|
||||||
acc[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Request data from the internal agent by sending a message to the process
|
|
||||||
// via stdin. This is a blocking call, so it will wait for the agent to
|
|
||||||
// respond before returning.
|
|
||||||
private async requestData(nodeList: string[]) {
|
|
||||||
if (this.exhausted()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait for the process to be spawned, busy waiting is gross
|
|
||||||
while (this.spawnProcess === null) {
|
|
||||||
await setTimeout(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the request to the agent, without waiting for a response.
|
|
||||||
// The live data invalidator will re-request the data if it is not
|
|
||||||
// available in the cache anyways.
|
|
||||||
const data = JSON.stringify({ NodeIDs: nodeList });
|
|
||||||
this.spawnProcess.stdin?.write(`${data}\n`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const diskSchema = type({
|
|
||||||
key: 'string',
|
|
||||||
value: 'unknown',
|
|
||||||
expires: 'number?',
|
|
||||||
}).array();
|
|
||||||
|
|
||||||
// A persistent HashMap with a TTL for each key
|
|
||||||
class TimedCache<V> {
|
|
||||||
private _cache = new Map<string, V>();
|
|
||||||
private _timings = new Map<string, number>();
|
|
||||||
|
|
||||||
// Default TTL is 1 minute
|
|
||||||
private defaultTTL: number;
|
|
||||||
private filePath: string;
|
|
||||||
private writeLock = false;
|
|
||||||
|
|
||||||
// Last flush ID is essentially a hash of the flush contents
|
|
||||||
// Prevents unnecessary flushing if nothing has changed
|
|
||||||
private lastFlushId = '';
|
|
||||||
|
|
||||||
constructor(defaultTTL: number, filePath: string) {
|
|
||||||
this.defaultTTL = defaultTTL;
|
|
||||||
this.filePath = filePath;
|
|
||||||
|
|
||||||
// Load the cache from disk and then queue flushes every 10 seconds
|
|
||||||
this.load().then(() => {
|
|
||||||
setInterval(() => this.flush(), 10000);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key: string, value: V, ttl: number = this.defaultTTL) {
|
|
||||||
this._cache.set(key, value);
|
|
||||||
this._timings.set(key, Date.now() + ttl);
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key: string) {
|
|
||||||
const value = this._cache.get(key);
|
|
||||||
if (!value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expires = this._timings.get(key);
|
|
||||||
if (!expires || expires < Date.now()) {
|
|
||||||
this._cache.delete(key);
|
|
||||||
this._timings.delete(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map into a Record without any TTLs
|
|
||||||
toJSON() {
|
|
||||||
const result: Record<string, V> = {};
|
|
||||||
for (const [key, value] of this._cache.entries()) {
|
|
||||||
result[key] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// WARNING: This function expects that this.filePath is NOT ENOENT
|
|
||||||
private async load() {
|
|
||||||
const data = await readFile(this.filePath, 'utf-8');
|
|
||||||
const cache = () => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(data);
|
|
||||||
} catch (e) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const diskData = cache();
|
|
||||||
if (diskData === undefined) {
|
|
||||||
log.error('agent', 'Failed to load cache at %s', this.filePath);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cacheData = diskSchema(diskData);
|
|
||||||
if (cacheData instanceof type.errors) {
|
|
||||||
log.debug('agent', 'Failed to load cache at %s', this.filePath);
|
|
||||||
log.debug('agent', 'Error details: %s', cacheData.toString());
|
|
||||||
|
|
||||||
// Skip loading the cache (it should be overwritten soon)
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const { key, value, expires } of diskData) {
|
|
||||||
this._cache.set(key, value);
|
|
||||||
this._timings.set(key, expires);
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info('agent', 'Loaded cache from %s', this.filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async flush() {
|
|
||||||
const data = Array.from(this._cache.entries()).map(([key, value]) => {
|
|
||||||
return { key, value, expires: this._timings.get(key) };
|
|
||||||
});
|
|
||||||
|
|
||||||
if (data.length === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate the hash of the data
|
|
||||||
const dumpData = JSON.stringify(data);
|
|
||||||
const sha = createHash('sha256').update(dumpData).digest('hex');
|
|
||||||
if (sha === this.lastFlushId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// We need to lock the writeLock so that we don't try to write
|
|
||||||
// to the file while we're already writing to it
|
|
||||||
while (this.writeLock) {
|
|
||||||
await setTimeout(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.writeLock = true;
|
|
||||||
await writeFile(this.filePath, dumpData, 'utf-8');
|
|
||||||
log.debug('agent', 'Flushed cache to %s', this.filePath);
|
|
||||||
this.lastFlushId = sha;
|
|
||||||
this.writeLock = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+105
-110
@@ -1,90 +1,37 @@
|
|||||||
import { readFile } from 'node:fs/promises';
|
import * as oidc from 'openid-client';
|
||||||
import * as client from 'openid-client';
|
|
||||||
import log from '~/utils/log';
|
import log from '~/utils/log';
|
||||||
import type { HeadplaneConfig } from '../config/schema';
|
import { HeadplaneConfig } from '../config/schema';
|
||||||
|
|
||||||
async function loadClientSecret(path: string) {
|
export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
|
||||||
// We need to interpolate environment variables into the path
|
|
||||||
// Path formatting can be like ${ENV_NAME}/path/to/secret
|
|
||||||
const matches = path.match(/\${(.*?)}/g);
|
|
||||||
let resolvedPath = path;
|
|
||||||
|
|
||||||
if (matches) {
|
export async function configureOidcAuth(config: OidcConfig) {
|
||||||
for (const match of matches) {
|
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
|
||||||
const env = match.slice(2, -1);
|
let clientAuthMethod: oidc.ClientAuth;
|
||||||
const value = process.env[env];
|
switch (config.token_endpoint_auth_method) {
|
||||||
if (!value) {
|
|
||||||
log.error('config', 'Environment variable %s is not set', env);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug('config', 'Interpolating %s with %s', match, value);
|
|
||||||
resolvedPath = resolvedPath.replace(match, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
log.debug('config', 'Reading client secret from %s', resolvedPath);
|
|
||||||
const secret = await readFile(resolvedPath, 'utf-8');
|
|
||||||
if (secret.trim().length === 0) {
|
|
||||||
log.error('config', 'Empty OIDC client secret');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return secret.trim();
|
|
||||||
} catch (error) {
|
|
||||||
log.error('config', 'Failed to read client secret from %s', path);
|
|
||||||
log.error('config', 'Error: %s', error);
|
|
||||||
log.debug('config', 'Error details: %o', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clientAuthMethod(
|
|
||||||
method: string,
|
|
||||||
): (secret: string) => client.ClientAuth {
|
|
||||||
switch (method) {
|
|
||||||
case 'client_secret_post':
|
|
||||||
return client.ClientSecretPost;
|
|
||||||
case 'client_secret_basic':
|
case 'client_secret_basic':
|
||||||
return client.ClientSecretBasic;
|
clientAuthMethod = oidc.ClientSecretBasic(config.client_secret!);
|
||||||
|
break;
|
||||||
|
case 'client_secret_post':
|
||||||
|
clientAuthMethod = oidc.ClientSecretPost(config.client_secret!);
|
||||||
|
break;
|
||||||
case 'client_secret_jwt':
|
case 'client_secret_jwt':
|
||||||
return client.ClientSecretJwt;
|
clientAuthMethod = oidc.ClientSecretJwt(config.client_secret!);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
throw new Error('Invalid client authentication method');
|
throw new Error('Invalid client authentication method');
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// Loads and configures an OIDC client to support OIDC authentication.
|
let oidcClient: oidc.Configuration;
|
||||||
// This runs under the assumption the OIDC configuration exists and is valid.
|
|
||||||
// If it is invalid, Headplane automatically disables it.
|
|
||||||
//
|
|
||||||
// TODO: Support custom endpoints instead of relying on OIDC discovery.
|
|
||||||
// This will enable us to support servers like GitHub that do not support
|
|
||||||
// nor advertise a .well-known endpoint.
|
|
||||||
export async function createOidcClient(
|
|
||||||
config: NonNullable<HeadplaneConfig['oidc']>,
|
|
||||||
) {
|
|
||||||
// const secret = await loadClientSecret(oidc);
|
|
||||||
const secret = config.client_secret_path
|
|
||||||
? await loadClientSecret(config.client_secret_path)
|
|
||||||
: config.client_secret;
|
|
||||||
|
|
||||||
if (!secret) {
|
|
||||||
log.error('config', 'Missing an OIDC client secret');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
|
|
||||||
try {
|
try {
|
||||||
const oidc = await client.discovery(
|
const discovery = await oidc.discovery(
|
||||||
new URL(config.issuer),
|
new URL(config.issuer),
|
||||||
config.client_id,
|
config.client_id,
|
||||||
secret,
|
config.client_secret!, // TODO: Fix this config schema
|
||||||
clientAuthMethod(config.token_endpoint_auth_method)(secret),
|
clientAuthMethod,
|
||||||
);
|
);
|
||||||
|
|
||||||
const metadata = oidc.serverMetadata();
|
const meta = discovery.serverMetadata();
|
||||||
if (!metadata.authorization_endpoint) {
|
if (!meta.authorization_endpoint) {
|
||||||
log.error(
|
log.error(
|
||||||
'config',
|
'config',
|
||||||
'Issuer discovery did not return `authorization_endpoint`',
|
'Issuer discovery did not return `authorization_endpoint`',
|
||||||
@@ -93,70 +40,118 @@ export async function createOidcClient(
|
|||||||
'config',
|
'config',
|
||||||
'OIDC server does not support authorization code flow',
|
'OIDC server does not support authorization code flow',
|
||||||
);
|
);
|
||||||
|
log.error('config', 'You may need to set this manually in the config');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metadata.token_endpoint) {
|
if (!meta.token_endpoint) {
|
||||||
log.error('config', 'Issuer discovery did not return `token_endpoint`');
|
log.error('config', 'Issuer discovery did not return `token_endpoint`');
|
||||||
log.error('config', 'OIDC server does not support token exchange');
|
log.error(
|
||||||
|
'config',
|
||||||
|
'OIDC server does not support authorization code flow',
|
||||||
|
);
|
||||||
|
log.error('config', 'You may need to set this manually in the config');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If this field is missing, assume the server supports all response types
|
if (!meta.userinfo_endpoint) {
|
||||||
// and that we can continue safely.
|
log.error(
|
||||||
if (metadata.response_types_supported) {
|
'config',
|
||||||
if (!metadata.response_types_supported.includes('code')) {
|
'Issuer discovery did not return `userinfo_endpoint`',
|
||||||
log.error(
|
);
|
||||||
'config',
|
log.error('config', 'OIDC server does not support user info endpoint');
|
||||||
'Issuer discovery `response_types_supported` does not include `code`',
|
log.error('config', 'You may need to set this manually in the config');
|
||||||
);
|
return;
|
||||||
log.error('config', 'OIDC server does not support code flow');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (metadata.token_endpoint_auth_methods_supported) {
|
if (meta.token_endpoint_auth_methods_supported) {
|
||||||
if (
|
if (
|
||||||
!metadata.token_endpoint_auth_methods_supported.includes(
|
!meta.token_endpoint_auth_methods_supported.includes(
|
||||||
config.token_endpoint_auth_method,
|
config.token_endpoint_auth_method,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
log.error(
|
log.error(
|
||||||
'config',
|
'config',
|
||||||
'Issuer discovery `token_endpoint_auth_methods_supported` does not include `%s`',
|
'OIDC server does not support client authentication method %s',
|
||||||
config.token_endpoint_auth_method,
|
config.token_endpoint_auth_method,
|
||||||
);
|
);
|
||||||
log.error(
|
log.error(
|
||||||
'config',
|
'config',
|
||||||
'OIDC server does not support %s',
|
'Supported methods: %s',
|
||||||
config.token_endpoint_auth_method,
|
meta.token_endpoint_auth_methods_supported.join(', '),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!metadata.userinfo_endpoint) {
|
log.debug('config', 'OIDC discovery successful');
|
||||||
log.error(
|
|
||||||
'config',
|
|
||||||
'Issuer discovery did not return `userinfo_endpoint`',
|
|
||||||
);
|
|
||||||
log.error('config', 'OIDC server does not support userinfo endpoint');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug('config', 'OIDC client created successfully');
|
|
||||||
log.info('config', 'Using %s as the OIDC issuer', config.issuer);
|
|
||||||
log.debug(
|
log.debug(
|
||||||
'config',
|
'config',
|
||||||
'Authorization endpoint: %s',
|
'Authorization endpoint: %s',
|
||||||
metadata.authorization_endpoint,
|
meta.authorization_endpoint,
|
||||||
);
|
);
|
||||||
log.debug('config', 'Token endpoint: %s', metadata.token_endpoint);
|
log.debug('config', 'Token endpoint: %s', meta.token_endpoint);
|
||||||
log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint);
|
log.debug('config', 'Userinfo endpoint: %s', meta.userinfo_endpoint);
|
||||||
return oidc;
|
|
||||||
} catch (error) {
|
// Manually construct the endpoints to coalesce with config if needed
|
||||||
log.error('config', 'Failed to discover OIDC issuer');
|
oidcClient = new oidc.Configuration(
|
||||||
log.error('config', 'Error: %s', error);
|
{
|
||||||
log.debug('config', 'Error details: %o', error);
|
issuer: config.issuer,
|
||||||
|
authorization_endpoint:
|
||||||
|
config.authorization_endpoint || meta.authorization_endpoint,
|
||||||
|
token_endpoint: config.token_endpoint || meta.token_endpoint,
|
||||||
|
userinfo_endpoint: config.userinfo_endpoint || meta.userinfo_endpoint,
|
||||||
|
},
|
||||||
|
config.client_id,
|
||||||
|
config.client_secret!,
|
||||||
|
clientAuthMethod,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log.error('config', 'OIDC discovery failed: %s', err);
|
||||||
|
log.debug('config', 'Error details: %o', err);
|
||||||
|
log.error(
|
||||||
|
'config',
|
||||||
|
'This may be an error, or the server may not support discovery',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!config.authorization_endpoint ||
|
||||||
|
!config.token_endpoint ||
|
||||||
|
!config.userinfo_endpoint
|
||||||
|
) {
|
||||||
|
log.error(
|
||||||
|
'config',
|
||||||
|
'Endpoints are not fully configured, cannot continue',
|
||||||
|
);
|
||||||
|
log.error(
|
||||||
|
'config',
|
||||||
|
'You must set authorization_endpoint, token_endpoint and userinfo_endpoint manually in the config or fix the discovery issue',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
oidcClient = new oidc.Configuration(
|
||||||
|
{
|
||||||
|
issuer: config.issuer,
|
||||||
|
authorization_endpoint: config.authorization_endpoint,
|
||||||
|
token_endpoint: config.token_endpoint,
|
||||||
|
userinfo_endpoint: config.userinfo_endpoint,
|
||||||
|
},
|
||||||
|
config.client_id,
|
||||||
|
config.client_secret!,
|
||||||
|
clientAuthMethod,
|
||||||
|
);
|
||||||
|
|
||||||
|
log.debug('config', 'Using manually configured endpoints');
|
||||||
|
log.debug(
|
||||||
|
'config',
|
||||||
|
'Authorization endpoint: %s',
|
||||||
|
config.authorization_endpoint,
|
||||||
|
);
|
||||||
|
log.debug('config', 'Token endpoint: %s', config.token_endpoint);
|
||||||
|
log.debug('config', 'Userinfo endpoint: %s', config.userinfo_endpoint);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info('config', 'Successfully configured OIDC authentication');
|
||||||
|
return oidcClient;
|
||||||
}
|
}
|
||||||
|
|||||||
+226
-206
@@ -1,13 +1,13 @@
|
|||||||
import { open, readFile } from 'node:fs/promises';
|
import { createHash } from 'node:crypto';
|
||||||
|
import { open, readFile, rm } from 'node:fs/promises';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import { exit } from 'node:process';
|
import { eq } from 'drizzle-orm';
|
||||||
import {
|
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver';
|
||||||
CookieSerializeOptions,
|
import { EncryptJWT, jwtDecrypt } from 'jose';
|
||||||
Session,
|
import { createCookie } from 'react-router';
|
||||||
SessionStorage,
|
import { ulid } from 'ulidx';
|
||||||
createCookieSessionStorage,
|
|
||||||
} from 'react-router';
|
|
||||||
import log from '~/utils/log';
|
import log from '~/utils/log';
|
||||||
|
import { users } from '../db/schema';
|
||||||
import { Capabilities, Roles } from './roles';
|
import { Capabilities, Roles } from './roles';
|
||||||
|
|
||||||
export interface AuthSession {
|
export interface AuthSession {
|
||||||
@@ -22,6 +22,17 @@ export interface AuthSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface JWTSession {
|
||||||
|
api_key: string;
|
||||||
|
user: {
|
||||||
|
subject: string;
|
||||||
|
name: string;
|
||||||
|
email?: string;
|
||||||
|
username?: string;
|
||||||
|
picture?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface OidcFlowSession {
|
export interface OidcFlowSession {
|
||||||
state: 'flow';
|
state: 'flow';
|
||||||
oidc: {
|
oidc: {
|
||||||
@@ -32,264 +43,243 @@ export interface OidcFlowSession {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type JoinedSession = AuthSession | OidcFlowSession;
|
interface AuthSessionOptions {
|
||||||
interface Error {
|
secret: string;
|
||||||
error: string;
|
db: LibSQLDatabase;
|
||||||
}
|
oidcUsersFile?: string;
|
||||||
|
cookie: {
|
||||||
interface CookieOptions {
|
name: string;
|
||||||
name: string;
|
secure: boolean;
|
||||||
secure: boolean;
|
maxAge: number;
|
||||||
maxAge: number;
|
domain?: string;
|
||||||
secrets: string[];
|
};
|
||||||
domain?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class Sessionizer {
|
class Sessionizer {
|
||||||
private storage: SessionStorage<JoinedSession, Error>;
|
private options: AuthSessionOptions;
|
||||||
private caps: Record<string, { c: Capabilities; oo?: boolean }>;
|
|
||||||
private capsPath?: string;
|
|
||||||
|
|
||||||
constructor(
|
constructor(options: AuthSessionOptions) {
|
||||||
options: CookieOptions,
|
this.options = options;
|
||||||
caps: Record<string, { c: Capabilities; oo?: boolean }>,
|
|
||||||
capsPath?: string,
|
|
||||||
) {
|
|
||||||
this.caps = caps;
|
|
||||||
this.capsPath = capsPath;
|
|
||||||
this.storage = createCookieSessionStorage({
|
|
||||||
cookie: {
|
|
||||||
...options,
|
|
||||||
httpOnly: true,
|
|
||||||
path: __PREFIX__, // Only match on the prefix
|
|
||||||
sameSite: 'lax', // TODO: Strictify with Domain,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// This throws on the assumption that auth is already checked correctly
|
// This throws on the assumption that auth is already checked correctly
|
||||||
// on something that wraps the route calling auth. The top-level routes
|
// on something that wraps the route calling auth. The top-level routes
|
||||||
// that call this are wrapped with try/catch to handle the error.
|
// that call this are wrapped with try/catch to handle the error.
|
||||||
async auth(request: Request) {
|
async auth(request: Request) {
|
||||||
const cookie = request.headers.get('cookie');
|
return decodeSession(request, this.options);
|
||||||
const session = await this.storage.getSession(cookie);
|
|
||||||
const type = session.get('state');
|
|
||||||
if (!type) {
|
|
||||||
throw new Error('Session state not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (type !== 'auth') {
|
|
||||||
throw new Error('Session is not authenticated');
|
|
||||||
}
|
|
||||||
|
|
||||||
return session as Session<AuthSession, Error>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
roleForSubject(subject: string): keyof typeof Roles | undefined {
|
async createSession(
|
||||||
const role = this.caps[subject]?.c;
|
payload: JWTSession,
|
||||||
if (!role) {
|
maxAge = this.options.cookie.maxAge,
|
||||||
|
) {
|
||||||
|
// TODO: What the hell is this garbage
|
||||||
|
return createSession(payload, {
|
||||||
|
...this.options,
|
||||||
|
cookie: {
|
||||||
|
...this.options.cookie,
|
||||||
|
maxAge,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async destroySession() {
|
||||||
|
return destroySession(this.options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async roleForSubject(
|
||||||
|
subject: string,
|
||||||
|
): Promise<keyof typeof Roles | undefined> {
|
||||||
|
const [user] = await this.options.db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.sub, subject))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// We need this in string form based on Object.keys of the roles
|
// We need this in string form based on Object.keys of the roles
|
||||||
for (const [key, value] of Object.entries(Roles)) {
|
for (const [key, value] of Object.entries(Roles)) {
|
||||||
if (value === role) {
|
if (value === user.caps) {
|
||||||
return key as keyof typeof Roles;
|
return key as keyof typeof Roles;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onboardForSubject(subject: string) {
|
|
||||||
return this.caps[subject]?.oo ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Given an OR of capabilities, check if the session has the required
|
// Given an OR of capabilities, check if the session has the required
|
||||||
// capabilities. If not, return false. Can throw since it calls auth()
|
// capabilities. If not, return false. Can throw since it calls auth()
|
||||||
async check(request: Request, capabilities: Capabilities) {
|
async check(request: Request, capabilities: Capabilities) {
|
||||||
const session = await this.auth(request);
|
const session = await this.auth(request);
|
||||||
const { subject } = session.get('user') ?? {};
|
|
||||||
if (!subject) {
|
// This is the subject we set on API key based sessions. API keys
|
||||||
|
// inherently imply admin access so we return true for all checks.
|
||||||
|
if (session.user.subject === 'unknown-non-oauth') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [user] = await this.options.db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.sub, session.user.subject))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This is the subject we set on API key based sessions. API keys
|
return (capabilities & user.caps) === capabilities;
|
||||||
// inherently imply admin access so we return true for all checks.
|
|
||||||
if (subject === 'unknown-non-oauth') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the role does not exist, then this is a new subject that we have
|
|
||||||
// not seen before. Since this is new, we set access to the lowest
|
|
||||||
// level by default which is the member role.
|
|
||||||
//
|
|
||||||
// This also allows us to avoid configuring preventing sign ups with
|
|
||||||
// OIDC, since the default sign up logic gives member which does not
|
|
||||||
// have access to the UI whatsoever.
|
|
||||||
const role = this.caps[subject];
|
|
||||||
if (!role) {
|
|
||||||
const memberRole = await this.registerSubject(subject);
|
|
||||||
return (capabilities & memberRole.c) === capabilities;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (capabilities & role.c) === capabilities;
|
|
||||||
}
|
|
||||||
|
|
||||||
async checkSubject(subject: string, capabilities: Capabilities) {
|
|
||||||
// This is the subject we set on API key based sessions. API keys
|
|
||||||
// inherently imply admin access so we return true for all checks.
|
|
||||||
if (subject === 'unknown-non-oauth') {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the role does not exist, then this is a new subject that we have
|
|
||||||
// not seen before. Since this is new, we set access to the lowest
|
|
||||||
// level by default which is the member role.
|
|
||||||
//
|
|
||||||
// This also allows us to avoid configuring preventing sign ups with
|
|
||||||
// OIDC, since the default sign up logic gives member which does not
|
|
||||||
// have access to the UI whatsoever.
|
|
||||||
const role = this.caps[subject];
|
|
||||||
if (!role) {
|
|
||||||
const memberRole = await this.registerSubject(subject);
|
|
||||||
return (capabilities & memberRole.c) === capabilities;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (capabilities & role.c) === capabilities;
|
|
||||||
}
|
|
||||||
|
|
||||||
// This code is very simple, if the user does not exist in the database
|
|
||||||
// file then we register it with the lowest level of access. If the user
|
|
||||||
// database is empty, the first user to sign in will be given the owner
|
|
||||||
// role.
|
|
||||||
private async registerSubject(subject: string) {
|
|
||||||
if (this.caps[subject]) {
|
|
||||||
return this.caps[subject];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(this.caps).length === 0) {
|
|
||||||
log.debug('auth', 'First user registered as owner: %s', subject);
|
|
||||||
this.caps[subject] = { c: Roles.owner };
|
|
||||||
await this.flushUserDatabase();
|
|
||||||
return this.caps[subject];
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug('auth', 'New user registered as member: %s', subject);
|
|
||||||
this.caps[subject] = { c: Roles.member };
|
|
||||||
await this.flushUserDatabase();
|
|
||||||
return this.caps[subject];
|
|
||||||
}
|
|
||||||
|
|
||||||
private async flushUserDatabase() {
|
|
||||||
if (!this.capsPath) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = Object.entries(this.caps).map(([u, { c, oo }]) => ({
|
|
||||||
u,
|
|
||||||
c,
|
|
||||||
oo,
|
|
||||||
}));
|
|
||||||
try {
|
|
||||||
const handle = await open(this.capsPath, 'w');
|
|
||||||
await handle.write(JSON.stringify(data));
|
|
||||||
await handle.close();
|
|
||||||
} catch (error) {
|
|
||||||
log.error('config', 'Error writing user database file: %s', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Updates the capabilities and roles of a subject
|
// Updates the capabilities and roles of a subject
|
||||||
async reassignSubject(subject: string, role: keyof typeof Roles) {
|
async reassignSubject(subject: string, role: keyof typeof Roles) {
|
||||||
// Check if we are owner
|
// Check if we are owner
|
||||||
if (this.roleForSubject(subject) === 'owner') {
|
const subjectRole = await this.roleForSubject(subject);
|
||||||
|
if (subjectRole === 'owner') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.caps[subject] = {
|
await this.options.db
|
||||||
...this.caps[subject], // Preserve the existing capabilities if any
|
.update(users)
|
||||||
c: Roles[role],
|
.set({
|
||||||
};
|
caps: Roles[role],
|
||||||
|
})
|
||||||
|
.where(eq(users.sub, subject));
|
||||||
|
|
||||||
await this.flushUserDatabase();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Overrides the onboarding status for a subject
|
|
||||||
async overrideOnboarding(subject: string, onboarding: boolean) {
|
|
||||||
this.caps[subject] = {
|
|
||||||
...this.caps[subject], // Preserve the existing capabilities if any
|
|
||||||
oo: onboarding,
|
|
||||||
};
|
|
||||||
await this.flushUserDatabase();
|
|
||||||
}
|
|
||||||
|
|
||||||
getOrCreate<T extends JoinedSession = AuthSession>(request: Request) {
|
|
||||||
return this.storage.getSession(request.headers.get('cookie')) as Promise<
|
|
||||||
Session<T, Error>
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy(session: Session) {
|
|
||||||
return this.storage.destroySession(session);
|
|
||||||
}
|
|
||||||
|
|
||||||
commit(session: Session, options?: CookieSerializeOptions) {
|
|
||||||
return this.storage.commitSession(session, options);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSessionStorage(
|
async function createSession(payload: JWTSession, options: AuthSessionOptions) {
|
||||||
options: CookieOptions,
|
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
|
||||||
usersPath?: string,
|
const jwt = await new EncryptJWT({
|
||||||
) {
|
...payload,
|
||||||
const map: Record<
|
})
|
||||||
string,
|
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' })
|
||||||
{
|
.setIssuedAt()
|
||||||
c: number;
|
.setExpirationTime('1d')
|
||||||
oo?: boolean;
|
.setIssuer('urn:tale:headplane')
|
||||||
}
|
.setAudience('urn:tale:headplane')
|
||||||
> = {};
|
.setJti(ulid())
|
||||||
if (usersPath) {
|
.encrypt(secret);
|
||||||
// We need to load our users from the file (default to empty map)
|
|
||||||
// We then translate each user into a capability object using the helper
|
|
||||||
// method defined in the roles.ts file
|
|
||||||
const data = await loadUserFile(usersPath);
|
|
||||||
log.debug('config', 'Loaded %d users from database', data.length);
|
|
||||||
|
|
||||||
for (const user of data) {
|
const cookie = createCookie(options.cookie.name, {
|
||||||
map[user.u] = {
|
...options.cookie,
|
||||||
c: user.c,
|
path: __PREFIX__,
|
||||||
oo: user.oo,
|
});
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Sessionizer(options, map, usersPath);
|
return cookie.serialize(jwt);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadUserFile(path: string) {
|
async function decodeSession(request: Request, options: AuthSessionOptions) {
|
||||||
|
const cookieHeader = request.headers.get('cookie');
|
||||||
|
if (cookieHeader === null) {
|
||||||
|
throw new Error('No session cookie found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const cookie = createCookie(options.cookie.name, {
|
||||||
|
...options.cookie,
|
||||||
|
path: __PREFIX__,
|
||||||
|
});
|
||||||
|
|
||||||
|
const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
|
||||||
|
if (cookieValue === null) {
|
||||||
|
throw new Error('Session cookie is empty');
|
||||||
|
}
|
||||||
|
|
||||||
|
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
|
||||||
|
const { payload } = await jwtDecrypt(cookieValue, secret, {
|
||||||
|
issuer: 'urn:tale:headplane',
|
||||||
|
audience: 'urn:tale:headplane',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Safe since we encode the session directly into the JWT
|
||||||
|
return payload as unknown as JWTSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function destroySession(options: AuthSessionOptions) {
|
||||||
|
const cookie = createCookie(options.cookie.name, {
|
||||||
|
...options.cookie,
|
||||||
|
path: __PREFIX__,
|
||||||
|
});
|
||||||
|
|
||||||
|
return cookie.serialize('', {
|
||||||
|
expires: new Date(0),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSessionStorage(options: AuthSessionOptions) {
|
||||||
|
if (options.oidcUsersFile) {
|
||||||
|
await migrateUserDatabase(options.oidcUsersFile, options.db);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Sessionizer(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
|
||||||
|
log.info('config', 'Migrating old user database from %s', path);
|
||||||
const realPath = resolve(path);
|
const realPath = resolve(path);
|
||||||
|
|
||||||
|
log.warn(
|
||||||
|
'config',
|
||||||
|
'oidc.user_storage_file is deprecated and will be removed in Headplane 0.7.0',
|
||||||
|
);
|
||||||
|
log.warn(
|
||||||
|
'config',
|
||||||
|
'You can ignore this warning if you do not use OIDC authentication.',
|
||||||
|
);
|
||||||
|
log.warn(
|
||||||
|
'config',
|
||||||
|
'Data will be automatically migrated to the new SQL database.',
|
||||||
|
);
|
||||||
|
log.warn(
|
||||||
|
'config',
|
||||||
|
'Refer to server.data_path to ensure this path is mounted correctly is using Docker.',
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const handle = await open(realPath, 'a+');
|
const handle = await open(realPath, 'a+');
|
||||||
log.info('config', 'Using user database file at %s', realPath);
|
|
||||||
await handle.close();
|
await handle.close();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.info('config', 'User database file not accessible at %s', realPath);
|
log.warn('config', 'Failed to migrate old user database at %s', realPath);
|
||||||
|
log.warn(
|
||||||
|
'config',
|
||||||
|
'This is not an error, but existing users will not be migrated',
|
||||||
|
);
|
||||||
|
log.warn('config', 'Unable to open user database file: %s', error);
|
||||||
log.debug('config', 'Error details: %s', error);
|
log.debug('config', 'Error details: %s', error);
|
||||||
exit(1);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info('config', 'Found old user database file at %s', realPath);
|
||||||
|
log.info('config', 'Migrating user database to the new SQL database');
|
||||||
|
|
||||||
|
let migratableUsers: {
|
||||||
|
u: string;
|
||||||
|
c: number;
|
||||||
|
oo?: boolean;
|
||||||
|
}[];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const data = await readFile(realPath, 'utf8');
|
const data = await readFile(realPath, 'utf8');
|
||||||
|
if (data.trim().length === 0) {
|
||||||
|
log.info('config', 'Old user database file is empty, nothing to migrate');
|
||||||
|
log.info(
|
||||||
|
'config',
|
||||||
|
'You SHOULD remove oidc.user_storage_file from your config!',
|
||||||
|
);
|
||||||
|
await rm(realPath, { force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const users = JSON.parse(data.trim()) as {
|
const users = JSON.parse(data.trim()) as {
|
||||||
u?: string;
|
u?: string;
|
||||||
c?: number;
|
c?: number;
|
||||||
oo?: boolean;
|
oo?: boolean;
|
||||||
}[];
|
}[];
|
||||||
|
|
||||||
// Never trust user input
|
migratableUsers = users.filter(
|
||||||
return users.filter(
|
|
||||||
(user) => user.u !== undefined && user.c !== undefined,
|
(user) => user.u !== undefined && user.c !== undefined,
|
||||||
) as {
|
) as {
|
||||||
u: string;
|
u: string;
|
||||||
@@ -297,8 +287,38 @@ async function loadUserFile(path: string) {
|
|||||||
oo?: boolean;
|
oo?: boolean;
|
||||||
}[];
|
}[];
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.debug('config', 'Error reading user database file: %s', error);
|
log.warn('config', 'Error reading old user database file: %s', error);
|
||||||
log.debug('config', 'Using empty user database');
|
log.warn('config', 'Not migrating any users');
|
||||||
return [];
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (migratableUsers.length === 0) {
|
||||||
|
log.info('config', 'No users found in the old database to migrate');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
'config',
|
||||||
|
'Migrating %d users from the old database',
|
||||||
|
migratableUsers.length,
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = await db
|
||||||
|
.insert(users)
|
||||||
|
.values(
|
||||||
|
migratableUsers.map((user) => ({
|
||||||
|
id: ulid(),
|
||||||
|
sub: user.u,
|
||||||
|
caps: user.c,
|
||||||
|
onboarded: user.oo ?? false,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.onConflictDoNothing({
|
||||||
|
target: users.sub,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
log.info('config', 'Migrated %d users successfully', updated.length);
|
||||||
|
log.info('config', 'Removed old user database file %s', realPath);
|
||||||
|
await rm(realPath, { force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user