Compare commits

...

81 Commits
0.5.4 ... main

Author SHA1 Message Date
David Gillespie
dbdb759a7e update tailwind
Some checks failed
Build / native (push) Has been cancelled
Build / nix (push) Has been cancelled
Release / Docker Release (push) Has been cancelled
Automated / flake-inputs (push) Has been cancelled
2025-05-09 23:44:57 -06:00
github-actions[bot]
6716e5f0b0
chore: update flake.lock (#202)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-04 15:25:02 -04:00
github-actions[bot]
346b44ec69
chore: update flake.lock (#196)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-03 23:46:31 -04:00
George Ntoutsos
faa61b0f1d
feat: add filtering by container label for Docker integration (#194) 2025-04-24 19:03:33 -04:00
github-actions[bot]
6b63fe209f
chore: update flake.lock (#190)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-20 15:38:03 -04:00
github-actions[bot]
c8507cff7c
chore: update flake.lock (#180)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-15 11:00:11 -04:00
Federico Cerutti
b86f4461c0
feat: restart with headscale, update server.js path in systemd example (#184) 2025-04-10 09:08:43 -04:00
Aarnav Tale
77b510c927
docs: oops 2025-04-05 13:14:00 -04:00
Aarnav Tale
5adcb8c582
ci: skip ci build on certain paths 2025-04-05 12:07:31 -04:00
Aarnav Tale
524c5eb639
docs: docker version tags had an extra char 2025-04-05 11:58:32 -04:00
Aarnav Tale
2894c664d3
docs: fix tag typo 2025-04-05 11:56:59 -04:00
Aarnav Tale
5c2d08decd
chore: update dep tooling 2025-04-04 16:09:10 -04:00
Aarnav Tale
f2e8c6ae4c
chore: update dep tooling 2025-04-04 16:06:22 -04:00
Aarnav Tale
c3e727842a
docs: mention versioning policy 2025-04-04 15:44:16 -04:00
Aarnav Tale
96345ab0a6
chore: switch from nightly to pr based incubration 2025-04-04 10:19:37 -04:00
Aarnav Tale
fe2d7cb57a
chore: v0.5.10 2025-04-03 23:44:19 -04:00
Aarnav Tale
66c7d9a327
chore: v0.5.9 2025-04-03 23:24:13 -04:00
Aarnav Tale
f2747ada94
fix: suppress date and button hydration warnings 2025-04-03 16:54:12 -04:00
Aarnav Tale
0ad578e651
fix: prevent user rename if they are an oidc user 2025-04-03 16:25:30 -04:00
Aarnav Tale
8d1132606a
fix: add aria-label to radio groups 2025-04-03 16:22:52 -04:00
Aarnav Tale
5e332c4a5c
fix: filter out empty users in auth-keys, potential headscale bug 2025-04-03 16:10:39 -04:00
Aarnav Tale
69c6fc4847
chore: v0.5.8 2025-04-03 13:11:33 -04:00
Aarnav Tale
9b09b13b5f
feat: do not login loop if disable_api_key_login is true 2025-04-03 12:57:55 -04:00
Aarnav Tale
cf90f3dd32
chore: auto-create /var/lib/headscale in docker 2025-04-03 12:57:55 -04:00
Aarnav Tale
72c1174cb3 fix: make the hidden URL link aria compatible 2025-04-03 12:57:06 -04:00
Aarnav Tale
63bfad77ce fix: add api-error file 2025-04-03 12:57:06 -04:00
Aarnav Tale
41223b89b3 chore: update to headscale 0.25.1 2025-04-03 12:57:06 -04:00
Aarnav Tale
93d5c2ed29 chore: update actions to include next branch 2025-04-03 12:57:06 -04:00
Aarnav Tale
6a94e815f2 feat: improve error returning and parsing logic 2025-04-03 12:57:06 -04:00
Aarnav Tale
234020eec5 feat: support acl capabilities check 2025-04-03 12:57:06 -04:00
Aarnav Tale
58cc7b742c feat: make machine actions permission locked 2025-04-03 12:57:06 -04:00
Aarnav Tale
259d150fc4 feat: add capabilities enforcement on users 2025-04-03 12:57:06 -04:00
Aarnav Tale
5d3fada266 feat: add permissions check on dns page 2025-04-03 12:57:06 -04:00
Aarnav Tale
9d046a0cf6 fix: use new logger on oidc utils 2025-04-03 12:57:06 -04:00
Aarnav Tale
1fb084451d fix: fix integrations not loading 2025-04-03 12:57:06 -04:00
Aarnav Tale
d5fb8a2966 feat: support skipping onboarding 2025-04-03 12:57:06 -04:00
Aarnav Tale
7b1340c93e fix: make reassign dialog unactionable if editing owner 2025-04-03 12:57:06 -04:00
Aarnav Tale
16a8122f85 fix: only use basename in ssr build 2025-04-03 12:57:06 -04:00
Aarnav Tale
7d61ad50c4 feat: oops commit the user role change page 2025-04-03 12:57:06 -04:00
Aarnav Tale
103a826178 chore: remove unused code in the user overview 2025-04-03 12:57:06 -04:00
Aarnav Tale
090dec1ca6
chore: update docs 2025-04-02 17:03:58 -04:00
Aarnav Tale
8596a56375
chore: v0.5.7 2025-04-02 14:47:50 -04:00
Aarnav Tale
000ec620b4
fix: serve css and js assets correctly 2025-04-02 14:27:23 -04:00
Aarnav Tale
83a69792ea
chore: v0.5.6 2025-04-02 13:47:45 -04:00
Aarnav Tale
fee5f423a6
chore: update nix hash 2025-04-02 13:40:50 -04:00
Aarnav Tale
d698cf5478
fix: open file in a+ to avoid read issues 2025-04-02 13:40:01 -04:00
Aarnav Tale
80c987f383
feat: implement onboarding for non-registered users 2025-04-02 13:26:58 -04:00
Aarnav Tale
17d477bf0f
fix: join copied commands without a space 2025-04-02 13:25:33 -04:00
Aarnav Tale
5e5c7c4c7a
fix: remove unreleased feature and ignore invalid keys in config 2025-04-02 10:16:34 -04:00
Aarnav Tale
2e383ddabe
feat: reimplement user actions 2025-04-01 12:27:44 -04:00
Aarnav Tale
2299907932
chore: document user login state 2025-03-29 14:28:11 -04:00
Aarnav Tale
3771890f98
fix: disable live data fetching when a dialog is open 2025-03-29 14:27:56 -04:00
Aarnav Tale
bf02015dc7
feat: begin working on user auth 2025-03-29 14:12:15 -04:00
Aarnav Tale
8429b19c4a
docs: use git tags for bare metal 2025-03-29 14:10:46 -04:00
Aarnav Tale
9a5952adcb
fix: split email for username if preferred_username is unavailable 2025-03-29 12:16:38 -04:00
Aarnav Tale
222ac7a279
chore: make prefixes.v4/6 optional 2025-03-27 12:20:47 -04:00
Aarnav Tale
6f40f9cfac
feat: rework github actions 2025-03-24 16:49:54 -04:00
Aarnav Tale
457cbc45e6
fix: do not bundle ssr deps in dev 2025-03-24 16:26:47 -04:00
Aarnav Tale
aac8a9ef12 fix: update nix hash 2025-03-24 16:15:38 -04:00
Aarnav Tale
b8d22beb17 feat: bundle node_modules into the server 2025-03-24 16:15:38 -04:00
Aarnav Tale
cac64a6fbe chore: remove old server code 2025-03-24 16:15:38 -04:00
Aarnav Tale
5918d0e501 fix: allow hostname passthrough for hono node 2025-03-24 16:15:38 -04:00
Aarnav Tale
03acebb23e fix: env variables did not resolve in prod 2025-03-24 16:15:38 -04:00
Aarnav Tale
73ea35980d feat: switch agent fetching to the server side
this brings the benefit of fitting in the revalidator lifecycle we have
created via the useLiveData hook.
2025-03-24 16:15:38 -04:00
Aarnav Tale
9a1051b9af feat: reimplement websocket to use hono 2025-03-24 16:15:38 -04:00
Aarnav Tale
c066b3064d fix: make useLiveData a context that is pausable 2025-03-24 16:15:38 -04:00
Aarnav Tale
98d02bb595 chore: migrate patching to HeadscaleConfig 2025-03-24 16:15:38 -04:00
Aarnav Tale
2964ff295e fix: set config after loading 2025-03-24 16:15:38 -04:00
Aarnav Tale
b0a3f9d5fd chore: update deps 2025-03-24 16:15:38 -04:00
Aarnav Tale
34cfee7cff feat: reach an initial working stage 2025-03-24 16:15:38 -04:00
Aarnav Tale
8db323b63f style: use biome in zed settings 2025-03-24 16:15:38 -04:00
Aarnav Tale
08c25caca3 chore: add deps and include 2025-03-24 16:15:38 -04:00
Aarnav Tale
cbbd64e91a feat: initial server side systems 2025-03-24 16:15:38 -04:00
Aarnav Tale
48fc0f7ef3
fix: remove unnecessary title in templates 2025-03-18 12:25:19 -04:00
Aarnav Tale
23fd2bbda2
chore: update nix sha 2025-03-18 01:59:16 -04:00
Aarnav Tale
05837963c4
chore: FUNDING 2025-03-18 01:56:17 -04:00
Aarnav Tale
2a16115e69
feat: add issue templates 2025-03-18 01:54:36 -04:00
Aarnav Tale
5675ecdeac
chore: add 'community' things 2025-03-18 01:32:05 -04:00
Aarnav Tale
29424366a8
chore: v0.5.5 2025-03-18 00:46:54 -04:00
Aarnav Tale
c47346df52
fix: do not require authkey 2025-03-18 00:46:35 -04:00
Aarnav Tale
92dedf51aa
fix: ignore ws_agents if there are no agents connected 2025-03-18 00:43:52 -04:00
135 changed files with 6181 additions and 4720 deletions

View File

@ -1,7 +0,0 @@
ROOT_API_KEY=abcdefghijklmnopqrstuvwxyz
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
DISABLE_API_KEY_LOGIN=true
HEADSCALE_CONTAINER=headscale
HOST=0.0.0.0
PORT=3000
CONFIG_FILE=/etc/headscale/config.yaml

2
.github/FUNDING.yml vendored Normal file
View File

@ -0,0 +1,2 @@
github: tale
ko_fi: atale

31
.github/ISSUE_TEMPLATE/bug-report.yaml vendored Normal file
View File

@ -0,0 +1,31 @@
name: Bug Report
description: Report an issue with Headplane
assignees: [tale]
labels: [bug, triage]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the issue and steps to reproduce it.
If applicable, include any error messages or screenshots.
If this is not an issue with Headplane, but an issue with your
environment, please consider opening a discussion instead.
placeholder: e.g. "When I try to upload a file, I get an error message."
validations:
required: true
- type: input
attributes:
label: Headplane Version
description: What version of Headplane are you using?
placeholder: e.g. "v0.5.5"
validations:
required: true
- type: input
attributes:
label: Headscale Version
description: What version of Headscale are you using?
placeholder: e.g. "v0.25.1"
validations:
required: true

1
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View File

@ -0,0 +1 @@
blank_issues_enabled: false

View File

@ -0,0 +1,15 @@
name: Feature Request
description: Request a new feature or enhancement for Headplane
assignees: [tale]
labels: [enhancement, triage]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the feature you would like to see added.
Please include any relevant context, such as why this feature is
important and how it would benefit other users beyond yourself.
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
validations:
required: true

33
.github/workflows/automated.yaml vendored Normal file
View File

@ -0,0 +1,33 @@
name: Automated
on:
schedule:
- cron: "0 8 * * 0"
workflow_dispatch:
concurrency:
group: automation-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: write # Read/write access to the repository
pull-requests: write # Allow creating pull requests
jobs:
flake-inputs:
name: flake-inputs
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: DeterminateSystems/update-flake-lock@main
with:
pr-title: "chore: update flake.lock"
pr-labels: |
automated

76
.github/workflows/build.yaml vendored Normal file
View File

@ -0,0 +1,76 @@
name: Build
on:
push:
paths-ignore:
- ".zed/**"
- "assets/**"
- "docs/**"
- "CHANGELOG.md"
- "README.md"
branches:
- "main"
- "next"
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
jobs:
native:
name: native
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install node.js
uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm build
nix:
name: nix
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- name: Check flake inputs
uses: DeterminateSystems/flake-checker-action@main
- name: Check flake outputs
run: nix flake check --all-systems

55
.github/workflows/next.yaml vendored Normal file
View File

@ -0,0 +1,55 @@
name: Pre-release (next)
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: pre-release-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
jobs:
publish:
# Ensure the action only runs if manually dispatched or a PR on the `next` branch in the *main* repository is opened or synchronized.
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
name: Docker Pre-release
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=next
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64

View File

@ -1,44 +0,0 @@
name: Publish Nightly Docker Image
on:
schedule:
- cron: "0 8 * * *"
workflow_dispatch:
jobs:
publish:
name: Build and Publish Nightly
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=edge,branch=main
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64

View File

@ -1,20 +0,0 @@
name: Update flake.lock
on:
workflow_dispatch:
schedule:
- cron: "0 0 * * 0"
jobs:
update-flake-inputs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
- uses: DeterminateSystems/update-flake-lock@main
with:
pr-title: "chore: update flake.lock"
pr-labels: |
dependencies
automated
- uses: DeterminateSystems/flake-checker-action@main
- run: nix flake check --all-systems

View File

@ -1,24 +0,0 @@
name: Nix CI
on:
pull_request:
workflow_dispatch:
push:
branches:
- main
tags:
- v?[0-9]+.[0-9]+.[0-9]+*
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
nix-ci:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: DeterminateSystems/nix-installer-action@main
with:
determinate: true
- uses: DeterminateSystems/flake-checker-action@main
- run: nix flake check --all-systems

View File

@ -1,46 +0,0 @@
name: Publish Docker Image
on:
push:
tags:
- "*"
jobs:
publish:
name: Build and Publish
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=false
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64

View File

@ -1,44 +0,0 @@
name: "Build"
on:
push:
branches:
- "main"
pull_request:
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install node.js
uses: actions/setup-node@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Build
run: pnpm build

53
.github/workflows/release.yaml vendored Normal file
View File

@ -0,0 +1,53 @@
name: Release
on:
push:
tags:
- "*"
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
jobs:
docker:
name: Docker Release
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64

1
.gitignore vendored
View File

@ -2,4 +2,5 @@ node_modules
/.react-router
/.cache
/build
/test
.env

2
.npmrc
View File

@ -1,2 +1,2 @@
side-effects-cache = false
shamefully-hoist = true
public-hoist-pattern[]=*hono*

2
.tool-versions Normal file
View File

@ -0,0 +1,2 @@
pnpm 10.4.0
node 22

17
.zed/settings.json Normal file
View File

@ -0,0 +1,17 @@
{
"formatter": {
"language_server": {
"name": "biome"
}
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
},
"languages": {
"YAML": {
"tab_size": 2,
"hard_tabs": false
}
}
}

View File

@ -1,3 +1,45 @@
### 0.5.10 (April 4, 2025)
- Fix an issue where other prefernences to skip onboarding affected every user.
### 0.5.9 (April 3, 2025)
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
- Suppress hydration errors for any fields with a date in it.
### 0.5.8 (April 3, 2025)
- You can now skip the onboarding page if desired.
- Added the UI to change user roles in the dashboard.
- Fixed an issue where integrations would throw instead of loading properly.
- Loading the ACL page no longer spams blank updates to the Headscale database (fixes [#151](https://github.com/tale/headplane/issues/151))
- Automatically create `/var/lib/headplane` in the Docker container (fixes [#166](https://github.com/tale/headplane/issues/166))
- OIDC logout with `disable_api_key_login` set to true will not automatically login again (fixes [#149](https://github.com/tale/headplane/issues/149))
### 0.5.7 (April 2, 2025)
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
### 0.5.6 (April 2, 2025)
### IMPORTANT
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously *ANY* accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
Implemented *proper* authentication methods for OIDC.
This is a large update and copies the permission system from Tailscale.
Permissions are not automatically derived from OIDC, but they can be configured via the UI.
Additionally, certain roles give certain capabilities, limiting access to parts of the dashboard.
By default, new users will have a `member` role which forbids access to the UI.
If there are no users, the first user will be given an `owner` role which cannot be removed.
**Changes**:
- Switched the internal server to use `hono` for better performance.
- Fixed an issue that caused dialogs to randomly refocus every 3 seconds.
- Headplane will not send API requests when the tab is not focused.
- Continue loosening the configuration requirements for Headscale (part of an ongoing effort).
- Unknown values in the Headplane config no longer cause a crash.
- Fixed an issue that caused copied commands to have a random space (fixes [#161](https://github.com/tale/headplane/issues/161))
### 0.5.5 (March 18, 2025)
- Hotfix an issue that caused Headplane to crash if no agents are available
### 0.5.4 (March 18, 2025)
- Fixed a typo in the Kubernetes documentation
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))

View File

@ -9,15 +9,10 @@ RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
RUN pnpm prune --prod
FROM node:22-alpine
RUN mkdir -p /var/lib/headplane
WORKDIR /app
COPY --from=build /app/build /app/build
COPY --from=build /app/node_modules /app/node_modules
RUN echo '{"type":"module"}' > /app/package.json
EXPOSE 3000
ENV NODE_ENV=production
ENV HOST=0.0.0.0
CMD [ "node", "./build/headplane/server.js" ]
CMD [ "node", "./build/server/index.js" ]

View File

@ -44,6 +44,18 @@ There are 2 ways to deploy Headplane:
Simple mode does not include the automatic management of DNS and Headplane
settings, requiring manual editing and reloading when making changes.
### Versioning
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
Pre-release builds are available under the `next` tag and get updated when a new release
PR is opened and actively in testing.
### Contributing
Headplane is an open-source project and contributions are welcome! If you have
any suggestions, bug reports, or feature requests, please open an issue. Also
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
---
<picture>
<source
media="(prefers-color-scheme: dark)"

View File

@ -53,5 +53,11 @@ func httpToWs(controlURL string) (string, error) {
return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
}
// We also need to append /_dial to the path
if u.Path[len(u.Path)-1] != '/' {
u.Path += "/"
}
u.Path += "_dial"
return u.String(), nil
}

View File

@ -7,6 +7,7 @@ export interface AttributeProps {
value: string;
isCopyable?: boolean;
link?: string;
suppressHydrationWarning?: boolean;
}
export default function Attribute({
@ -14,6 +15,7 @@ export default function Attribute({
value,
link,
isCopyable,
suppressHydrationWarning,
}: AttributeProps) {
return (
<dl className="flex items-center w-full gap-x-1">
@ -27,6 +29,7 @@ export default function Attribute({
)}
</dt>
<dd
suppressHydrationWarning={suppressHydrationWarning}
className={cn(
'rounded-lg truncate w-full px-2.5 py-1 text-sm',
'flex items-center gap-x-1',
@ -54,7 +57,12 @@ export default function Attribute({
}, 1000);
}}
>
<p className="truncate">{value}</p>
<p
suppressHydrationWarning={suppressHydrationWarning}
className="truncate"
>
{value}
</p>
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
</button>

View File

@ -24,9 +24,7 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
type="button"
className="bottom-0 right-0 absolute"
onClick={async (event) => {
const text = Array.isArray(children)
? children.join(' ')
: children;
const text = Array.isArray(children) ? children.join('') : children;
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {

View File

@ -1,4 +1,4 @@
import React, { cloneElement, useRef } from 'react';
import React, { cloneElement, useEffect, useRef } from 'react';
import {
type AriaDialogProps,
type AriaModalOverlayProps,
@ -19,6 +19,7 @@ import IconButton, { IconButtonProps } from '~/components/IconButton';
import Text from '~/components/Text';
import Title from '~/components/Title';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
export interface DialogProps extends OverlayTriggerProps {
children:
@ -30,6 +31,7 @@ export interface DialogProps extends OverlayTriggerProps {
}
function Dialog(props: DialogProps) {
const { pause, resume } = useLiveData();
const state = useOverlayTriggerState(props);
const { triggerProps, overlayProps } = useOverlayTrigger(
{
@ -38,6 +40,14 @@ function Dialog(props: DialogProps) {
state,
);
useEffect(() => {
if (state.isOpen) {
pause();
} else {
resume();
}
}, [state.isOpen]);
if (Array.isArray(props.children)) {
const [button, panel] = props.children;
return (

View File

@ -1,16 +1,36 @@
import { AlertIcon } from '@primer/octicons-react';
import { isRouteErrorResponse, useRouteError } from 'react-router';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
interface Props {
type?: 'full' | 'embedded';
}
function getMessage(error: Error | unknown) {
function getMessage(error: Error | unknown): {
title: string;
message: string;
} {
if (error instanceof ResponseError) {
if (error.responseObject?.message) {
return {
title: 'Headscale Error',
message: String(error.responseObject.message),
};
}
return {
title: 'Headscale Error',
message: error.response,
};
}
if (!(error instanceof Error)) {
return 'An unknown error occurred';
return {
title: 'Unknown Error',
message: String(error),
};
}
let rootError = error;
@ -25,16 +45,22 @@ function getMessage(error: Error | unknown) {
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
return rootError.errors.map((error) => error.message).join('\n');
return {
title: 'Errors',
message: rootError.errors.map((error) => error.message).join('\n'),
};
}
return rootError.message;
return {
title: 'Error',
message: rootError.message,
};
}
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const message = getMessage(error);
const { title, message } = getMessage(error);
return (
<div
@ -48,12 +74,14 @@ export function ErrorPopup({ type = 'full' }: Props) {
<Card>
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">
{routing ? error.status : 'Error'}
{routing ? error.status : title}
</Card.Title>
<AlertIcon className="w-12 h-12 text-red-500" />
</div>
<Card.Text className="mt-4 text-lg">
{routing ? error.statusText : <Code>{message}</Code>}
<Card.Text
className={cn('mt-4 text-lg', routing ? 'font-normal' : 'font-mono')}
>
{routing ? error.data.message : message}
</Card.Text>
</Card>
</div>

View File

@ -1,10 +1,6 @@
import Link from '~/components/Link';
import cn from '~/utils/cn';
declare global {
const __VERSION__: string;
}
interface FooterProps {
url: string;
debug: boolean;
@ -34,7 +30,18 @@ export default function Footer({ url, debug }: FooterProps) {
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' — '}
Connecting to <strong className="blur-xs hover:blur-none">{url}</strong>
Connecting to{' '}
<button
type="button"
tabIndex={0} // Allows keyboard focus
className={cn(
'blur-sm hover:blur-none focus:blur-none transition',
'focus:outline-none focus:ring-2 rounded-sm',
)}
>
{url}
</button>
{/* Connecting to <strong className="blur-xs hover:blur-none">{url}</strong> */}
{debug && ' (Debug mode enabled)'}
</p>
</footer>

View File

@ -10,12 +10,21 @@ import {
import type { ReactNode } from 'react';
import { NavLink, useSubmit } from 'react-router';
import Menu from '~/components/Menu';
import { AuthSession } from '~/server/web/sessions';
import cn from '~/utils/cn';
import type { SessionData } from '~/utils/sessions.server';
interface Props {
configAvailable: boolean;
user?: SessionData['user'];
onboarding: boolean;
user?: AuthSession['user'];
access: {
ui: boolean;
machines: boolean;
dns: boolean;
users: boolean;
policy: boolean;
settings: boolean;
};
}
interface LinkProps {
@ -135,29 +144,49 @@ export default function Header(data: Props) {
) : undefined}
</div>
</div>
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
<TabLink
to="/machines"
name="Machines"
icon={<Server className="w-5" />}
/>
<TabLink to="/users" name="Users" icon={<Users className="w-5" />} />
<TabLink
to="/acls"
name="Access Control"
icon={<Lock className="w-5" />}
/>
{data.configAvailable ? (
<>
<TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} />
{data.access.ui && !data.onboarding ? (
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
{data.access.machines ? (
<TabLink
to="/settings"
name="Settings"
icon={<Settings className="w-5" />}
to="/machines"
name="Machines"
icon={<Server className="w-5" />}
/>
</>
) : undefined}
</nav>
) : undefined}
{data.access.users ? (
<TabLink
to="/users"
name="Users"
icon={<Users className="w-5" />}
/>
) : undefined}
{data.access.policy ? (
<TabLink
to="/acls"
name="Access Control"
icon={<Lock className="w-5" />}
/>
) : undefined}
{data.configAvailable ? (
<>
{data.access.dns ? (
<TabLink
to="/dns"
name="DNS"
icon={<Globe2 className="w-5" />}
/>
) : undefined}
{data.access.settings ? (
<TabLink
to="/settings"
name="Settings"
icon={<Settings className="w-5" />}
/>
) : undefined}
</>
) : undefined}
</nav>
) : undefined}
</header>
);
}

View File

@ -16,6 +16,8 @@ import cn from '~/utils/cn';
interface MenuProps extends MenuTriggerProps {
placement?: Placement;
isDisabled?: boolean;
disabledKeys?: Key[];
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>,
@ -23,8 +25,9 @@ interface MenuProps extends MenuTriggerProps {
}
// TODO: onAction is called twice for some reason?
// TODO: isDisabled per-prop
function Menu(props: MenuProps) {
const { placement = 'bottom' } = props;
const { placement = 'bottom', isDisabled, disabledKeys = [] } = props;
const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
@ -40,6 +43,7 @@ function Menu(props: MenuProps) {
<div>
{cloneElement(button, {
...menuTriggerProps,
isDisabled: isDisabled,
ref,
})}
{state.isOpen && (
@ -48,6 +52,7 @@ function Menu(props: MenuProps) {
...menuProps,
autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(),
disabledKeys,
})}
</Popover>
)}
@ -57,6 +62,7 @@ function Menu(props: MenuProps) {
interface MenuPanelProps extends AriaMenuProps<object> {
onClose?: () => void;
disabledKeys?: Key[];
}
function Panel(props: MenuPanelProps) {
@ -71,7 +77,12 @@ function Panel(props: MenuPanelProps) {
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
>
{[...state.collection].map((item) => (
<MenuSection key={item.key} section={item} state={state} />
<MenuSection
key={item.key}
section={item}
state={state}
disabledKeys={props.disabledKeys}
/>
))}
</ul>
);
@ -80,9 +91,10 @@ function Panel(props: MenuPanelProps) {
interface MenuSectionProps<T> {
section: Node<T>;
state: TreeState<T>;
disabledKeys?: Key[];
}
function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
const { itemProps, groupProps } = useMenuSection({
heading: section.rendered,
'aria-label': section['aria-label'],
@ -106,7 +118,12 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
<li {...itemProps}>
<ul {...groupProps}>
{[...section.childNodes].map((item) => (
<MenuItem key={item.key} item={item} state={state} />
<MenuItem
key={item.key}
item={item}
state={state}
isDisabled={disabledKeys?.includes(item.key)}
/>
))}
</ul>
</li>
@ -117,14 +134,14 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
interface MenuItemProps<T> {
item: Node<T>;
state: TreeState<T>;
isDisabled?: boolean;
}
function MenuItem<T>({ item, state }: MenuItemProps<T>) {
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const isFocused = state.selectionManager.focusedKey === item.key;
const isDisabled = state.selectionManager.isDisabled(item.key);
return (
<li

View File

@ -0,0 +1,78 @@
import { useRef } from 'react';
import {
AriaTabListProps,
AriaTabPanelProps,
useTab,
useTabList,
useTabPanel,
} from 'react-aria';
import { Item, Node, TabListState, useTabListState } from 'react-stately';
import cn from '~/utils/cn';
export interface OptionsProps extends AriaTabListProps<object> {
label: string;
className?: string;
}
function Options({ label, className, ...props }: OptionsProps) {
const state = useTabListState(props);
const ref = useRef<HTMLDivElement | null>(null);
const { tabListProps } = useTabList(props, state, ref);
return (
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className="flex items-center gap-2 overflow-x-scroll"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
))}
</div>
<OptionsPanel key={state.selectedItem?.key} state={state} />
</div>
);
}
export interface OptionsOptionProps {
item: Node<object>;
state: TabListState<object>;
}
function Option({ item, state }: OptionsOptionProps) {
const { key, rendered } = item;
const ref = useRef<HTMLDivElement | null>(null);
const { tabProps } = useTab({ key }, state, ref);
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-none focus:ring z-10',
'border border-headplane-100 dark:border-headplane-800',
)}
>
{rendered}
</div>
);
}
export interface OptionsPanelProps extends AriaTabPanelProps {
state: TabListState<object>;
}
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
const ref = useRef<HTMLDivElement | null>(null);
const { tabPanelProps } = useTabPanel(props, state, ref);
return (
<div {...tabPanelProps} ref={ref} className="w-full mt-2">
{state.selectedItem?.props.children}
</div>
);
}
export default Object.assign(Options, { Item });

View File

@ -0,0 +1,83 @@
import React, { createContext, useContext, useRef } from 'react';
import {
AriaRadioGroupProps,
AriaRadioProps,
VisuallyHidden,
useFocusRing,
} from 'react-aria';
import { RadioGroupState } from 'react-stately';
import cn from '~/utils/cn';
import { useRadio, useRadioGroup } from 'react-aria';
import { useRadioGroupState } from 'react-stately';
interface RadioGroupProps extends AriaRadioGroupProps {
children: React.ReactElement<RadioProps>[];
label: string;
className?: string;
}
const RadioContext = createContext<RadioGroupState | null>(null);
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
const state = useRadioGroupState(props);
const { radioGroupProps, labelProps } = useRadioGroup(
{
...props,
'aria-label': label,
},
state,
);
return (
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
<VisuallyHidden>
<span {...labelProps}>{label}</span>
</VisuallyHidden>
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
</div>
);
}
interface RadioProps extends AriaRadioProps {
label: string;
className?: string;
}
function Radio({ children, label, className, ...props }: RadioProps) {
const state = useContext(RadioContext);
const ref = useRef(null);
const { inputProps, isSelected, isDisabled } = useRadio(
{
...props,
'aria-label': label,
},
state!,
ref,
);
const { isFocusVisible, focusProps } = useFocusRing();
return (
<label className="flex items-center gap-2 text-sm">
<VisuallyHidden>
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
</VisuallyHidden>
<div
aria-hidden="true"
className={cn(
'w-5 h-5 aspect-square rounded-full p-1 border-2',
'border border-headplane-600 dark:border-headplane-300',
isFocusVisible ? 'ring-4' : '',
isDisabled ? 'opacity-50 cursor-not-allowed' : '',
isSelected
? 'border-[6px] border-headplane-900 dark:border-headplane-100'
: '',
className,
)}
/>
{children}
</label>
);
}
export default Object.assign(RadioGroup, { Radio });

View File

@ -1,34 +1,30 @@
import { XCircleFillIcon } from '@primer/octicons-react';
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { Outlet, useLoaderData } from 'react-router';
import { ErrorPopup } from '~/components/Error';
import type { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import { HeadscaleError, healthcheck, pull } from '~/utils/headscale';
import { destroySession, getSession } from '~/utils/sessions.server';
import { useLiveData } from '~/utils/useLiveData';
import log from '~server/utils/log';
import log from '~/utils/log';
export async function loader({ request }: LoaderFunctionArgs) {
let healthy = false;
try {
healthy = await healthcheck();
} catch (error) {
log.debug('APIC', 'Healthcheck failed %o', error);
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck();
const session = await context.sessions.auth(request);
// We shouldn't session invalidate if Headscale is down
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
if (healthy) {
// We can assert because shell ensures this is set
const session = await getSession(request.headers.get('Cookie'));
const apiKey = session.get('hsApiKey')!;
try {
await pull('v1/apikey', apiKey);
await context.client.get('v1/apikey', session.get('api_key')!);
} catch (error) {
if (error instanceof HeadscaleError) {
log.debug('APIC', 'API Key validation failed %o', error);
if (error instanceof ResponseError) {
log.debug('api', 'API Key validation failed %o', error);
return redirect('/login', {
headers: {
'Set-Cookie': await destroySession(session),
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
@ -41,7 +37,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
}
export default function Layout() {
useLiveData({ interval: 3000 });
const { healthy } = useLoaderData<typeof loader>();
return (
@ -71,3 +66,7 @@ export default function Layout() {
</>
);
}
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}

View File

@ -1,42 +1,168 @@
import { CircleCheckIcon } from 'lucide-react';
import {
LoaderFunctionArgs,
Outlet,
redirect,
useLoaderData,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import { hs_getConfig } from '~/utils/config/loader';
import { getSession } from '~/utils/sessions.server';
import type { AppContext } from '~server/context/app';
import { hp_getConfig } from '~server/context/global';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { User } from '~/types';
import log from '~/utils/log';
import toast from '~/utils/toast';
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
// There is a session, but it's not valid
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
// Onboarding is only a feature of the OIDC flow
if (context.oidc && !request.url.endsWith('/onboarding')) {
let onboarded = false;
const sessionUser = session.get('user');
if (sessionUser) {
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
onboarded = true;
} else {
try {
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
if (users.length === 0) {
onboarded = false;
}
const user = users.find((u) => {
if (u.provider !== 'oidc') {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
return true;
}
return subject === sessionUser.subject;
});
if (user) {
onboarded = true;
}
} catch (e) {
// If we cannot lookup users, just assume our user is onboarded
log.debug('api', 'Failed to lookup users %o', e);
onboarded = true;
}
}
}
if (!onboarded) {
return redirect('/onboarding');
}
}
const check = await context.sessions.check(request, Capabilities.ui_access);
return {
config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
debug: context.config.debug,
user: session.get('user'),
uiAccess: check,
access: {
ui: await context.sessions.check(request, Capabilities.ui_access),
dns: await context.sessions.check(request, Capabilities.read_network),
users: await context.sessions.check(request, Capabilities.read_users),
policy: await context.sessions.check(request, Capabilities.read_policy),
machines: await context.sessions.check(
request,
Capabilities.read_machines,
),
settings: await context.sessions.check(
request,
Capabilities.read_feature,
),
},
onboarding: request.url.endsWith('/onboarding'),
};
} catch {
// No session, so we can just return
return redirect('/login');
}
const context = hp_getConfig();
const { mode, config } = hs_getConfig();
return {
config,
url: context.headscale.public_url ?? context.headscale.url,
configAvailable: mode !== 'no',
debug: context.debug,
user: session.get('user'),
};
}
export default function Shell() {
const data = useLoaderData<typeof loader>();
return (
<>
<Header {...data} />
<Outlet />
{/* Always show the outlet if we are onboarding */}
{(data.onboarding ? true : data.uiAccess) ? (
<Outlet />
) : (
<Card className="mx-auto w-fit mt-24">
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">Connected</Card.Title>
<CircleCheckIcon className="w-10 h-10" />
</div>
<Card.Text className="my-4 text-lg">
Connect to Tailscale with your devices to access this Tailnet. Use
this command to help you get started:
</Card.Text>
<Button
className="flex text-md font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server=${data.url}`,
);
toast('Copied to clipboard');
}}
>
tailscale up --login-server={data.url}
</Button>
<p className="text-xs mt-1 opacity-50 text-center">
Click this button to copy the command.
</p>
<p className="mt-4 text-sm opacity-50">
Your account does not have access to the UI. Please contact your
administrator if you believe this is a mistake.
</p>
</Card>
)}
<Footer {...data} />
</>
);

View File

@ -12,6 +12,7 @@ import { ErrorPopup } from '~/components/Error';
import ProgressBar from '~/components/ProgressBar';
import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url';
import { LiveDataProvider } from '~/utils/live-data';
import { useToastQueue } from '~/utils/toast';
export const meta: MetaFunction = () => [
@ -29,21 +30,26 @@ export const links: LinksFunction = () => [
export function Layout({ children }: { readonly children: React.ReactNode }) {
const toastQueue = useToastQueue();
// LiveDataProvider is wrapped at the top level since dialogs and things
// that control its state are usually open in portal containers which
// are not a part of the normal React tree.
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
<Scripts />
</body>
</html>
<LiveDataProvider>
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
<Scripts />
</body>
</html>
</LiveDataProvider>
);
}

View File

@ -11,12 +11,11 @@ export default [
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'),
// API
route('/api/agent', 'routes/api/agent.ts'),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
route('/onboarding', 'routes/users/onboarding.tsx'),
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'),
layout('layouts/dashboard.tsx', [
...prefix('/machines', [
index('routes/machines/overview.tsx'),
@ -24,7 +23,7 @@ export default [
]),
route('/users', 'routes/users/overview.tsx'),
route('/acls', 'routes/acls/editor.tsx'),
route('/acls', 'routes/acls/overview.tsx'),
route('/dns', 'routes/dns/overview.tsx'),
...prefix('/settings', [

View File

@ -0,0 +1,113 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles';
import { data400, data403 } from '~/utils/res';
// We only check capabilities here and assume it is writable
// If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening.
export async function aclAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_policy,
);
if (!check) {
throw data403('You do not have permission to write to the ACL policy');
}
// Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData();
const policyData = formData.get('policy')?.toString();
if (!policyData) {
throw data400('Missing `policy` in the form data.');
}
try {
const { policy, updatedAt } = await context.client.put<{
policy: string;
updatedAt: string;
}>('v1/policy', session.get('api_key')!, {
policy: policyData,
});
return data({
success: true,
error: undefined,
policy,
updatedAt,
});
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('update is disabled')) {
// This means the policy is not writable
throw data403('Policy is not writable');
}
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
if (message.includes('parsing hujson')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err: hujson:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('unmarshalling policy')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('empty policy')) {
return data(
{
success: false,
error: 'Policy error: Supplied policy was empty',
policy: undefined,
updatedAt: undefined,
},
400,
);
}
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}

View File

@ -0,0 +1,64 @@
import { LoaderFunctionArgs } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles';
import { data403 } from '~/utils/res';
// The logic for deciding policy factors is very complicated because
// there are so many factors that need to be accounted for:
// 1. Does the user have permission to read the policy?
// 2. Does the user have permission to write to the policy?
// 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available.
// TODO: Consider adding back file editing mode instead of database
export async function aclLoader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_policy);
if (!check) {
throw data403('You do not have permission to read the ACL policy.');
}
const flags = {
// Can the user write to the ACL policy
access: await context.sessions.check(request, Capabilities.write_policy),
writable: false,
policy: '',
};
// Try to load the ACL policy from the API.
try {
const { policy, updatedAt } = await context.client.get<{
policy: string;
updatedAt: string | null;
}>('v1/policy', session.get('api_key')!);
// Successfully loaded the policy, mark it as readable
// If `updatedAt` is null, it means the policy is in file mode.
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('acl policy not found')) {
// This means the policy has never been initiated, and we can
// write to it to get it started or ignore it.
flags.policy = ''; // Start with an empty policy
flags.writable = true;
}
return flags;
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}

View File

@ -14,6 +14,7 @@ interface EditorProps {
onChange: (value: string) => void;
}
// TODO: Remove ClientOnly
export function Editor(props: EditorProps) {
const [light, setLight] = useState(false);
useEffect(() => {
@ -38,6 +39,8 @@ export function Editor(props: EditorProps) {
{() => (
<CodeMirror
value={props.value}
editable={!props.isDisabled} // Allow editing unless disabled
readOnly={props.isDisabled} // Use readOnly if disabled
height="100%"
extensions={[shopify.jsonc()]}
style={{ height: '100%' }}

View File

@ -1,24 +1,44 @@
import { AlertIcon } from '@primer/octicons-react';
import cn from '~/utils/cn';
import React from 'react';
import Card from '~/components/Card';
import Code from '~/components/Code';
interface Props {
message: string;
interface NoticeViewProps {
title: string;
children: React.ReactNode;
}
export function ErrorView({ message }: Props) {
export function NoticeView({ children, title }: NoticeViewProps) {
return (
<Card variant="flat" className="max-w-full mb-4">
<Card variant="flat" className="max-w-2xl my-8">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">Error</Card.Title>
<Card.Title className="text-xl mb-0">{title}</Card.Title>
<AlertIcon className="w-8 h-8 text-yellow-500" />
</div>
<Card.Text className="mt-4">{children}</Card.Text>
</Card>
);
}
interface ErrorViewProps {
children: string;
}
export function ErrorView({ children }: ErrorViewProps) {
const [title, ...rest] = children.split(':');
const formattedMessage = rest.length > 0 ? rest.join(':').trim() : children;
return (
<Card variant="flat" className="max-w-2xl mb-4">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">
{title.trim() ?? 'Error'}
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Could not apply changes to your ACL policy due to the following error:
Could not apply changes to the ACL policy:
<br />
<Code>{message}</Code>
<span className="font-mono">{formattedMessage}</span>
</Card.Text>
</Card>
);

View File

@ -1,4 +1,3 @@
import Spinner from '~/components/Spinner';
import cn from '~/utils/cn';
interface Props {

View File

@ -1,39 +0,0 @@
import { AlertIcon } from '@primer/octicons-react';
import cn from '~/utils/cn';
import Card from '~/components/Card';
import Code from '~/components/Code';
interface Props {
mode: 'file' | 'database';
}
export function Unavailable({ mode }: Props) {
return (
<Card variant="flat" className="max-w-prose mt-12">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">ACL Policy Unavailable</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Unable to load a valid ACL policy configuration. This is most likely due
to a misconfiguration in your Headscale configuration file.
</Card.Text>
{mode !== 'file' ? (
<p className="mt-4 text-sm">
According to your configuration, the ACL policy mode is set to{' '}
<Code>file</Code> but the ACL file is not available. Ensure that the{' '}
<Code>policy.path</Code> is set to a valid path in your Headscale
configuration.
</p>
) : (
<p className="mt-4 text-sm">
In order to fully utilize the ACL management features of Headplane,
please set <Code>policy.mode</Code> to either <Code>file</Code> or{' '}
<Code>database</Code> in your Headscale configuration.
</p>
)}
</Card>
);
}

View File

@ -1,300 +0,0 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useFetcher, useLoaderData, useRevalidator } from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Spinner from '~/components/Spinner';
import Tabs from '~/components/Tabs';
import { hs_getConfig } from '~/utils/config/loader';
import { HeadscaleError, pull, put } from '~/utils/headscale';
import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server';
import toast from '~/utils/toast';
import type { AppContext } from '~server/context/app';
import log from '~server/utils/log';
import { Differ, Editor } from './components/cm.client';
import { ErrorView } from './components/error';
import { Unavailable } from './components/unavailable';
export async function loader({ request }: LoaderFunctionArgs<AppContext>) {
const session = await getSession(request.headers.get('Cookie'));
// The way policy is handled in 0.23 of Headscale and later is verbose.
// The 2 ACL policy modes are either the database one or file one
//
// File: The ACL policy is readonly to the API and manually edited
// Database: The ACL policy is read/write to the API
//
// To determine if we first have an ACL policy available we need to check
// if fetching the v1/policy route gives us a 500 status code or a 200.
//
// 500 can mean many different things here unfortunately:
// - In file based that means the file is not accessible
// - In database mode this can mean that we have never set an ACL policy
// - In database mode this can mean that the ACL policy is not available
// - A general server error may have occurred
//
// Unfortunately the server errors are not very descriptive so we have to
// do some silly guesswork here. If we are running in an integration mode
// and have the Headscale configuration available to us, our assumptions
// can be more accurate, otherwise we just HAVE to assume that the ACL
// policy has never been set.
//
// We can do damage control by checking for write access and if we are not
// able to PUT an ACL policy on the v1/policy route, we can already know
// that the policy is at the very-least readonly or not available.
const { mode, config } = hs_getConfig();
let modeGuess = 'database'; // Assume database mode
if (mode !== 'no') {
modeGuess = config.policy?.mode ?? 'database';
}
// Attempt to load the policy, for both the frontend and for checking
// if we are able to write to the policy for write access
try {
const { policy } = await pull<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
);
let write = false; // On file mode we already know it's readonly
if (modeGuess === 'database' && policy.length > 0) {
try {
await put('v1/policy', session.get('hsApiKey')!, {
policy: policy,
});
write = true;
} catch (error) {
write = false;
log.debug('APIC', 'Failed to write to ACL policy with error %s', error);
}
}
return {
read: true,
write,
mode: modeGuess,
policy,
};
} catch {
// If we are explicit on file mode then this is the end of the road
if (modeGuess === 'file') {
return {
read: false,
write: false,
mode: modeGuess,
policy: null,
};
}
// Assume that we have write access otherwise?
// This is sort of a brittle assumption to make but we don't want
// to create a default policy if we don't have to.
return {
read: true,
write: true,
mode: modeGuess,
policy: null,
};
}
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send({ success: false, error: null }, 401);
}
try {
const { acl } = (await request.json()) as { acl: string };
const { policy } = await put<{ policy: string }>(
'v1/policy',
session.get('hsApiKey')!,
{
policy: acl,
},
);
return { success: true, policy, error: null };
} catch (error) {
log.debug('APIC', 'Failed to update ACL policy with error %s', error);
// @ts-ignore: TODO: Shut UP we know it's a string most of the time
const text = JSON.parse(error.message);
return send(
{ success: false, error: text.message },
{
status: error instanceof HeadscaleError ? error.status : 500,
},
);
}
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher<typeof action>();
const revalidator = useRevalidator();
const [acl, setAcl] = useState(data.policy ?? '');
const [toasted, setToasted] = useState(false);
useEffect(() => {
if (!fetcher.data || toasted) {
return;
}
if (fetcher.data.success) {
toast('Updated tailnet ACL policy');
} else {
toast('Failed to update tailnet ACL policy');
}
setToasted(true);
if (revalidator.state === 'idle') {
revalidator.revalidate();
}
}, [fetcher.data, toasted, data.policy]);
// The state for if the save and discard buttons should be disabled
// is pretty complicated to calculate and varies on different states.
const disabled = useMemo(() => {
if (!data.read || !data.write) {
return true;
}
// First check our fetcher states
if (fetcher.state === 'loading') {
return true;
}
if (revalidator.state === 'loading') {
return true;
}
// If we have a failed fetcher state allow the user to try again
if (fetcher.data?.success === false) {
return false;
}
return data.policy === acl;
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]);
return (
<div>
{data.read && !data.write ? (
<div className="mb-4">
<Notice>
The ACL policy is read-only. You can view the current policy but you
cannot make changes to it.
<br />
To resolve this, you need to set the ACL policy mode to database in
your Headscale configuration.
</Notice>
</div>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.success === false ? (
<ErrorView message={fetcher.data.error} />
) : undefined}
{data.read ? (
<>
<Tabs label="ACL Editor" className="mb-4">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor isDisabled={!data.write} value={acl} onChange={setAcl} />
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={data?.policy ?? ''} right={acl} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="w-1/2 text-center mt-4">
Previewing rules is not available yet. This feature is still
in development and is pretty complicated to implement.
Hopefully I will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={disabled}
onPress={() => {
setToasted(false);
fetcher.submit(
{
acl,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Save
</Button>
<Button
isDisabled={disabled}
onPress={() => {
setAcl(data?.policy ?? '');
}}
>
Discard Changes
</Button>
</>
) : (
<Unavailable mode={data.mode as 'database' | 'file'} />
)}
</div>
);
}

View File

@ -0,0 +1,173 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
ActionFunctionArgs,
LoaderFunctionArgs,
useFetcher,
useLoaderData,
useRevalidator,
} from 'react-router';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import type { LoadContext } from '~/server';
import toast from '~/utils/toast';
import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client';
import { ErrorView, NoticeView } from './components/error';
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
return aclLoader(request);
}
export async function action(request: ActionFunctionArgs<LoadContext>) {
return aclAction(request);
}
export default function Page() {
// Access is a write check here, we already check read in aclLoader
const { access, writable, policy } = useLoaderData<typeof loader>();
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
setCodePolicy(policy);
}
}, [policy]);
useEffect(() => {
if (!fetcher.data) {
// No data yet, return
return;
}
if (fetcher.data.success === true) {
toast('Updated policy');
revalidate();
}
}, [fetcher.data]);
return (
<div>
{!access ? (
<NoticeView title="ACL Policy restricted">
You do not have the necessary permissions to edit the Access Control
List policy. Please contact your administrator to request access or to
make changes to the ACL policy.
</NoticeView>
) : !writable ? (
<NoticeView title="Read-only ACL Policy">
The ACL policy mode is most likely set to <Code>file</Code> in your
Headscale configuration. This means that the ACL file cannot be edited
through the web interface. In order to resolve this, you'll need to
set <Code>acl.mode</Code> to <Code>database</Code> in your Headscale
configuration.
</NoticeView>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.error !== undefined ? (
<ErrorView>{fetcher.data.error}</ErrorView>
) : undefined}
<Tabs label="ACL Editor" className="mb-4">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor
isDisabled={disabled}
value={codePolicy}
onChange={setCodePolicy}
/>
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={policy} right={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="w-1/2 text-center mt-4">
Previewing rules is not available yet. This feature is still in
development and is pretty complicated to implement. Hopefully I
will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={
disabled ||
fetcher.state !== 'idle' ||
codePolicy.length === 0 ||
codePolicy === policy
}
onPress={() => {
const formData = new FormData();
console.log(codePolicy);
formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' });
}}
>
Save
</Button>
<Button
isDisabled={
disabled || fetcher.state !== 'idle' || codePolicy === policy
}
onPress={() => {
// Reset the editor to the original policy
setCodePolicy(policy);
}}
>
Discard Changes
</Button>
</div>
);
}

View File

@ -1,39 +0,0 @@
import { LoaderFunctionArgs } from 'react-router';
import { hp_getSingleton, hp_getSingletonUnsafe } from '~server/context/global';
export async function loader({ request }: LoaderFunctionArgs) {
const data = hp_getSingletonUnsafe('ws_agent_data');
if (!data) {
return new Response(JSON.stringify({ error: 'Agent data unavailable' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
});
}
const qp = new URLSearchParams(request.url.split('?')[1]);
const nodeIds = qp.get('node_ids')?.split(',');
if (!nodeIds) {
return new Response(JSON.stringify({ error: 'No node IDs provided' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
});
}
const entries = data.toJSON();
const missing = nodeIds.filter((nodeID) => !entries[nodeID]);
if (missing.length > 0) {
const requestCall = hp_getSingleton('ws_fetch_data');
requestCall(missing);
}
return new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json',
},
});
}

View File

@ -1,57 +1,60 @@
import { useEffect } from 'react';
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
redirect,
useSearchParams,
} from 'react-router';
import { Form, useActionData, useLoaderData } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import type { LoadContext } from '~/server';
import type { Key } from '~/types';
import { pull } from '~/utils/headscale';
import { commitSession, getSession } from '~/utils/sessions.server';
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session),
},
});
}
const context = hp_getConfig();
const disableApiKeyLogin = context.oidc?.disable_api_key_login;
let oidc = false;
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const qp = new URL(request.url).searchParams;
const state = qp.get('s');
try {
// Only set if OIDC is properly enabled anyways
hp_getSingleton('oidc_client');
oidc = true;
if (disableApiKeyLogin) {
return redirect('/oidc/start');
const session = await context.sessions.auth(request);
if (session.has('api_key')) {
return redirect('/machines');
}
} catch {}
const disableApiKeyLogin = context.config.oidc?.disable_api_key_login;
if (context.oidc && disableApiKeyLogin) {
// Prevents automatic redirect loop if OIDC is enabled and API key login is disabled
// Since logging out would just log back in based on the redirects
if (state !== 'logout') {
return redirect('/oidc/start');
}
}
return {
oidc,
apiKey: !disableApiKeyLogin,
oidc: context.oidc,
disableApiKeyLogin,
state,
};
}
export async function action({ request }: ActionFunctionArgs) {
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const formData = await request.formData();
const oidcStart = formData.get('oidc-start');
const session = await getSession(request.headers.get('Cookie'));
const session = await context.sessions.getOrCreate(request);
if (oidcStart) {
const context = hp_getConfig();
if (!context.oidc) {
throw new Error('An invalid OIDC configuration was provided');
throw new Error('OIDC is not enabled');
}
return redirect('/oidc/start');
@ -61,17 +64,24 @@ export async function action({ request }: ActionFunctionArgs) {
// Test the API key
try {
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey);
const apiKeys = await context.client.get<{ apiKeys: Key[] }>(
'v1/apikey',
apiKey,
);
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
if (!key) {
throw new Error('Invalid API key');
return {
error: 'Invalid API key',
};
}
const expiry = new Date(key.expiration);
const expiresIn = expiry.getTime() - Date.now();
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
session.set('hsApiKey', apiKey);
session.set('state', 'auth');
session.set('api_key', apiKey);
session.set('user', {
subject: 'unknown-non-oauth',
name: key.prefix,
@ -80,7 +90,7 @@ export async function action({ request }: ActionFunctionArgs) {
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session, {
'Set-Cookie': await context.sessions.commit(session, {
maxAge: expiresIn,
}),
},
@ -93,14 +103,47 @@ export async function action({ request }: ActionFunctionArgs) {
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const { state, disableApiKeyLogin, oidc } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [params] = useSearchParams();
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (state !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete('s');
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
window.history.replaceState(null, '', newUrl);
}
}, [state, params]);
if (state === 'logout') {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>You have been logged out</Card.Title>
<Card.Text>
You can now close this window. If you would like to log in again,
please refresh the page.
</Card.Text>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>Welcome to Headplane</Card.Title>
{data.apiKey ? (
{!disableApiKeyLogin ? (
<Form method="post">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
@ -125,19 +168,12 @@ export default function Page() {
</Button>
</Form>
) : undefined}
{data.oidc === true ? (
{oidc ? (
<Form method="POST">
{!data.apiKey ? (
<Card.Text className="mb-6">
Sign in with your authentication provider to continue. Your
administrator has disabled API key login.
</Card.Text>
) : undefined}
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full mt-2"
variant={data.apiKey ? 'light' : 'heavy'}
variant={disableApiKeyLogin ? 'heavy' : 'light'}
type="submit"
>
Single Sign-On

View File

@ -1,15 +1,28 @@
import { type ActionFunctionArgs, redirect } from 'react-router';
import { destroySession, getSession } from '~/utils/sessions.server';
import type { LoadContext } from '~/server';
export async function loader() {
return redirect('/machines');
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
return redirect('/login', {
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
return redirect('/login');
}
// When API key is disabled, we need to explicitly redirect
// with a logout state to prevent auto login again.
const url = context.config.oidc?.disable_api_key_login
? '/login?s=logout'
: '/login';
return redirect(url, {
headers: {
'Set-Cookie': await destroySession(session),
'Set-Cookie': await context.sessions.destroy(session),
},
});
}

View File

@ -1,68 +1,61 @@
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
import { commitSession, getSession } from '~/utils/sessions.server';
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
export async function loader({ request }: LoaderFunctionArgs) {
const { oidc } = hp_getConfig();
try {
if (!oidc) {
throw new Error('OIDC is not enabled');
}
hp_getSingleton('oidc_client');
} catch {
return send({ error: 'OIDC is not enabled' }, { status: 400 });
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/machines');
return redirect('/login');
}
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
return redirect('/machines');
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if (session.get('state') !== 'flow') {
return redirect('/login'); // Haven't started an OIDC flow
}
const codeVerifier = session.get('oidc_code_verif');
const state = session.get('oidc_state');
const nonce = session.get('oidc_nonce');
const redirectUri = session.get('oidc_redirect_uri');
if (!codeVerifier || !state || !nonce || !redirectUri) {
const payload = session.get('oidc')!;
const { code_verifier, state, nonce, redirect_uri } = payload;
if (!code_verifier || !state || !nonce || !redirect_uri) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
}
// Reconstruct the redirect URI using the query parameters
// and the one we saved in the session
const flowRedirectUri = new URL(redirectUri);
const flowRedirectUri = new URL(redirect_uri);
flowRedirectUri.search = url.search;
const flowOptions = {
redirect_uri: flowRedirectUri.toString(),
codeVerifier,
code_verifier,
state,
nonce: nonce === '<none>' ? undefined : nonce,
};
try {
const user = await finishAuthFlow(oidc, flowOptions);
session.set('user', user);
session.unset('oidc_code_verif');
session.unset('oidc_state');
session.unset('oidc_nonce');
const user = await finishAuthFlow(context.oidc, flowOptions);
session.unset('oidc');
const userSession = session as Session<AuthSession>;
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
session.set('hsApiKey', oidc.headscale_api_key);
userSession.set('user', user);
userSession.set('api_key', context.config.oidc?.headscale_api_key!);
userSession.set('state', 'auth');
return redirect('/machines', {
headers: {
'Set-Cookie': await commitSession(session),
'Set-Cookie': await context.sessions.commit(userSession),
},
});
} catch (error) {

View File

@ -1,37 +1,42 @@
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
import { send } from '~/utils/res';
import { commitSession, getSession } from '~/utils/sessions.server';
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (session.has('hsApiKey')) {
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if ((session as Session<AuthSession>).has('api_key')) {
return redirect('/machines');
}
const { oidc } = hp_getConfig();
try {
if (!oidc) {
throw new Error('OIDC is not enabled');
}
hp_getSingleton('oidc_client');
} catch {
return send({ error: 'OIDC is not enabled' }, { status: 400 });
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
const redirectUri = oidc.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(oidc, redirectUri);
session.set('oidc_code_verif', data.codeVerifier);
session.set('oidc_state', data.state);
session.set('oidc_nonce', data.nonce);
session.set('oidc_redirect_uri', redirectUri);
const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(
context.oidc,
redirectUri,
// We can't get here without the OIDC config being defined
context.config.oidc!.token_endpoint_auth_method,
);
session.set('state', 'flow');
session.set('oidc', {
state: data.state,
nonce: data.nonce,
code_verifier: data.codeVerifier,
redirect_uri: redirectUri,
});
return redirect(data.url, {
status: 302,
headers: {
'Set-Cookie': await commitSession(session),
'Set-Cookie': await context.sessions.commit(session),
},
});
}

View File

@ -1,16 +1,21 @@
import { ActionFunctionArgs, data } from 'react-router';
import { hs_getConfig, hs_patchConfig } from '~/utils/config/loader';
import { hp_getIntegration } from '~/utils/integration/loader';
import { auth } from '~/utils/sessions.server';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
export async function dnsAction({ request }: ActionFunctionArgs) {
const session = await auth(request);
if (!session) {
return data({ success: false }, 401);
export async function dnsAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const check = await context.sessions.check(
request,
Capabilities.write_network,
);
if (!check) {
return data({ success: false }, 403);
}
const { mode } = hs_getConfig();
if (mode !== 'rw') {
if (!context.hs.writable()) {
return data({ success: false }, 403);
}
@ -22,59 +27,60 @@ export async function dnsAction({ request }: ActionFunctionArgs) {
switch (action) {
case 'rename_tailnet':
return renameTailnet(formData);
return renameTailnet(formData, context);
case 'toggle_magic':
return toggleMagic(formData);
return toggleMagic(formData, context);
case 'remove_ns':
return removeNs(formData);
return removeNs(formData, context);
case 'add_ns':
return addNs(formData);
return addNs(formData, context);
case 'remove_domain':
return removeDomain(formData);
return removeDomain(formData, context);
case 'add_domain':
return addDomain(formData);
return addDomain(formData, context);
case 'remove_record':
return removeRecord(formData);
return removeRecord(formData, context);
case 'add_record':
return addRecord(formData);
return addRecord(formData, context);
default:
return data({ success: false }, 400);
}
}
async function renameTailnet(formData: FormData) {
async function renameTailnet(formData: FormData, context: LoadContext) {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.base_domain',
value: newName,
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function toggleMagic(formData: FormData) {
async function toggleMagic(formData: FormData, context: LoadContext) {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function removeNs(formData: FormData) {
async function removeNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
@ -82,15 +88,10 @@ async function removeNs(formData: FormData) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
@ -100,7 +101,7 @@ async function removeNs(formData: FormData) {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await hs_patchConfig([
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
@ -108,10 +109,11 @@ async function removeNs(formData: FormData) {
]);
}
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function addNs(formData: FormData) {
async function addNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
@ -119,16 +121,11 @@ async function addNs(formData: FormData) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
@ -139,7 +136,7 @@ async function addNs(formData: FormData) {
const servers = splits[splitName] ?? [];
servers.push(ns);
await hs_patchConfig([
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
@ -147,57 +144,49 @@ async function addNs(formData: FormData) {
]);
}
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function removeDomain(formData: FormData) {
async function removeDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function addDomain(formData: FormData) {
async function addDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const domains = config.dns.search_domains;
domains.push(domain);
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function removeRecord(formData: FormData) {
async function removeRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
@ -205,26 +194,22 @@ async function removeRecord(formData: FormData) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const records = config.dns.extra_records.filter(
(i) => i.name !== recordName || i.type !== recordType,
);
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function addRecord(formData: FormData) {
async function addRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
@ -233,20 +218,15 @@ async function addRecord(formData: FormData) {
return data({ success: false }, 400);
}
const { config, mode } = hs_getConfig();
if (mode !== 'rw') {
return data({ success: false }, 403);
}
const records = config.dns.extra_records;
records.push({ name: recordName, type: recordType, value: recordValue });
await hs_patchConfig([
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}

View File

@ -1,8 +1,9 @@
import type { ActionFunctionArgs } from 'react-router';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import Code from '~/components/Code';
import Notice from '~/components/Notice';
import { hs_getConfig } from '~/utils/config/loader';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import ManageDomains from './components/manage-domains';
import ManageNS from './components/manage-ns';
import ManageRecords from './components/manage-records';
@ -11,12 +12,31 @@ import ToggleMagic from './components/toggle-magic';
import { dnsAction } from './dns-actions';
// We do not want to expose every config value
export async function loader() {
const { config, mode } = hs_getConfig();
if (mode === 'no') {
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.hs.readable()) {
throw new Error('No configuration is available');
}
const check = await context.sessions.check(
request,
Capabilities.read_network,
);
if (!check) {
// Not authorized to view this page
throw new Error(
'You do not have permission to view this page. Please contact your administrator.',
);
}
const writablePermission = await context.sessions.check(
request,
Capabilities.write_network,
);
const config = context.hs.c!;
const dns = {
prefixes: config.prefixes,
magicDns: config.dns.magic_dns,
@ -29,7 +49,8 @@ export async function loader() {
return {
...dns,
mode,
access: writablePermission,
writable: context.hs.writable(),
};
}
@ -46,16 +67,22 @@ export default function Page() {
}
allNs.global = data.nameservers;
const isDisabled = data.mode !== 'rw';
const isDisabled = data.access === false || data.writable === false;
return (
<div className="flex flex-col gap-16 max-w-screen-lg">
{data.mode === 'rw' ? undefined : (
{data.writable ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to
the configuration
</Notice>
)}
{data.access ? undefined : (
<Notice>
Your permissions do not allow you to modify the DNS settings for this
tailnet.
</Notice>
)}
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
<ManageNS nameservers={allNs} isDisabled={isDisabled} />
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />

View File

@ -1,212 +0,0 @@
import type { ActionFunctionArgs } from 'react-router';
import { del, post } from '~/utils/headscale';
import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server';
import log from '~server/utils/log';
export async function menuAction(request: ActionFunctionArgs['request']) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send(
{ message: 'Unauthorized' },
{
status: 401,
},
);
}
const data = await request.formData();
if (!data.has('_method') || !data.has('id')) {
return send(
{ message: 'No method or ID provided' },
{
status: 400,
},
);
}
const id = String(data.get('id'));
const method = String(data.get('_method'));
switch (method) {
case 'delete': {
await del(`v1/node/${id}`, session.get('hsApiKey')!);
return { message: 'Machine removed' };
}
case 'expire': {
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!);
return { message: 'Machine expired' };
}
case 'rename': {
if (!data.has('name')) {
return send(
{ message: 'No name provided' },
{
status: 400,
},
);
}
const name = String(data.get('name'));
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!);
return { message: 'Machine renamed' };
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const route = String(data.get('route'));
const enabled = data.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
return { message: 'Route updated' };
}
case 'exit-node': {
if (!data.has('routes') || !data.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const routes = data.get('routes')?.toString().split(',') ?? [];
const enabled = data.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await Promise.all(
routes.map(async (route) => {
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
}),
);
return { message: 'Exit node updated' };
}
case 'move': {
if (!data.has('to')) {
return send(
{ message: 'No destination provided' },
{
status: 400,
},
);
}
const to = String(data.get('to'));
try {
await post(`v1/node/${id}/user`, session.get('hsApiKey')!, {
user: to,
});
return { message: `Moved node ${id} to ${to}` };
} catch (error) {
console.error(error);
return send(
{ message: `Failed to move node ${id} to ${to}` },
{
status: 500,
},
);
}
}
case 'tags': {
const tags =
data
.get('tags')
?.toString()
.split(',')
.filter((tag) => tag.trim() !== '') ?? [];
try {
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
tags,
});
return { message: 'Tags updated' };
} catch (error) {
log.debug('APIC', 'Failed to update tags: %s', error);
return send(
{ message: 'Failed to update tags' },
{
status: 500,
},
);
}
}
case 'register': {
const key = data.get('mkey')?.toString();
const user = data.get('user')?.toString();
if (!key) {
return send(
{ message: 'No machine key provided' },
{
status: 400,
},
);
}
if (!user) {
return send(
{ message: 'No user provided' },
{
status: 400,
},
);
}
try {
const qp = new URLSearchParams();
qp.append('user', user);
qp.append('key', key);
const url = `v1/node/register?${qp.toString()}`;
await post(url, session.get('hsApiKey')!, {
user,
key,
});
return {
success: true,
message: 'Machine registered',
};
} catch {
return send(
{
success: false,
message: 'Failed to register machine',
},
{
status: 500,
},
);
}
}
default: {
return send(
{ message: 'Invalid method' },
{
status: 400,
},
);
}
}
}

View File

@ -18,6 +18,7 @@ interface Props {
isAgent?: boolean;
magic?: string;
stats?: HostInfo;
isDisabled?: boolean;
}
export default function MachineRow({
@ -27,6 +28,7 @@ export default function MachineRow({
isAgent,
magic,
stats,
isDisabled,
}: Props) {
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
@ -96,7 +98,7 @@ export default function MachineRow({
return (
<tr
key={machine.id}
className="'group hover:bg-headplane-50 dark:hover:bg-headplane-950"
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
>
<td className="pl-0.5 py-2 focus-within:ring">
<Link
@ -152,18 +154,21 @@ export default function MachineRow({
</Menu>
</div>
</td>
<td className="py-2">
{stats !== undefined ? (
<>
<p className="leading-snug">{hinfo.getTSVersion(stats)}</p>
<p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(stats)}
</p>
</>
) : (
<p className="text-sm opacity-50">Unknown</p>
)}
</td>
{/* We pass undefined when agents are not enabled */}
{isAgent !== undefined ? (
<td className="py-2">
{stats !== undefined ? (
<>
<p className="leading-snug">{hinfo.getTSVersion(stats)}</p>
<p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(stats)}
</p>
</>
) : (
<p className="text-sm opacity-50">Unknown</p>
)}
</td>
) : undefined}
<td className="py-2">
<span
className={cn(
@ -175,7 +180,7 @@ export default function MachineRow({
isOnline={machine.online && !expired}
className="w-4 h-4"
/>
<p>
<p suppressHydrationWarning>
{machine.online && !expired
? 'Connected'
: new Date(machine.lastSeen).toLocaleString()}
@ -188,6 +193,7 @@ export default function MachineRow({
routes={routes}
users={users}
magic={magic}
isDisabled={isDisabled}
/>
</td>
</tr>

View File

@ -16,6 +16,7 @@ interface MenuProps {
users: User[];
magic?: string;
isFullButton?: boolean;
isDisabled?: boolean;
}
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
@ -26,6 +27,7 @@ export default function MachineMenu({
magic,
users,
isFullButton,
isDisabled,
}: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
@ -96,7 +98,7 @@ export default function MachineMenu({
/>
)}
<Menu>
<Menu isDisabled={isDisabled}>
{isFullButton ? (
<Menu.Button className="flex items-center gap-x-2">
<Cog className="h-5" />

View File

@ -8,12 +8,13 @@ import Menu from '~/components/Menu';
import Select from '~/components/Select';
import type { User } from '~/types';
export interface NewProps {
export interface NewMachineProps {
server: string;
users: User[];
isDisabled?: boolean;
}
export default function New(data: NewProps) {
export default function NewMachine(data: NewMachineProps) {
const [pushDialog, setPushDialog] = useState(false);
const [mkey, setMkey] = useState('');
const navigate = useNavigate();
@ -25,11 +26,8 @@ export default function New(data: NewProps) {
<Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4">
The machine key is given when you run{' '}
<Code isCopyable>
tailscale up --login-server=
{data.server}
</Code>{' '}
on your device.
<Code isCopyable>tailscale up --login-server={data.server}</Code> on
your device.
</Dialog.Text>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
@ -53,7 +51,7 @@ export default function New(data: NewProps) {
</Select>
</Dialog.Panel>
</Dialog>
<Menu>
<Menu isDisabled={data.isDisabled}>
<Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel
onAction={(key) => {

View File

@ -0,0 +1,247 @@
import type { ActionFunctionArgs } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { Machine } from '~/types';
import log from '~/utils/log';
import { data400, data403, data404, send } from '~/utils/res';
// TODO: Clean this up like dns-actions and user-actions
export async function machineAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_machines,
);
const apiKey = session.get('api_key')!;
const formData = await request.formData();
// TODO: Rename this to 'action_id' and 'node_id'
const action = formData.get('_method')?.toString();
const nodeId = formData.get('id')?.toString();
if (!action || !nodeId) {
return data400('Missing required parameters: _method and id');
}
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
apiKey,
);
const node = nodes.find((node) => node.id === nodeId);
if (!node) {
return data404(`Node with ID ${nodeId} not found`);
}
const subject = session.get('user')!.subject;
if (node.user.providerId?.split('/').pop() !== subject) {
if (!check) {
return data403('You do not have permission to act on this machine');
}
}
// TODO: Split up into methods
switch (action) {
case 'delete': {
await context.client.delete(`v1/node/${nodeId}`, session.get('api_key')!);
return { message: 'Machine removed' };
}
case 'expire': {
await context.client.post(
`v1/node/${nodeId}/expire`,
session.get('api_key')!,
);
return { message: 'Machine expired' };
}
case 'rename': {
if (!formData.has('name')) {
return send(
{ message: 'No name provided' },
{
status: 400,
},
);
}
const name = String(formData.get('name'));
await context.client.post(
`v1/node/${nodeId}/rename/${name}`,
session.get('api_key')!,
);
return { message: 'Machine renamed' };
}
case 'routes': {
if (!formData.has('route') || !formData.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const route = String(formData.get('route'));
const enabled = formData.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await context.client.post(
`v1/routes/${route}/${postfix}`,
session.get('api_key')!,
);
return { message: 'Route updated' };
}
case 'exit-node': {
if (!formData.has('routes') || !formData.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const routes = formData.get('routes')?.toString().split(',') ?? [];
const enabled = formData.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await Promise.all(
routes.map(async (route) => {
await context.client.post(
`v1/routes/${route}/${postfix}`,
session.get('api_key')!,
);
}),
);
return { message: 'Exit node updated' };
}
case 'move': {
if (!formData.has('to')) {
return send(
{ message: 'No destination provided' },
{
status: 400,
},
);
}
const to = String(formData.get('to'));
try {
await context.client.post(
`v1/node/${nodeId}/user`,
session.get('api_key')!,
{
user: to,
},
);
return { message: `Moved node ${nodeId} to ${to}` };
} catch (error) {
console.error(error);
return send(
{ message: `Failed to move node ${nodeId} to ${to}` },
{
status: 500,
},
);
}
}
case 'tags': {
const tags =
formData
.get('tags')
?.toString()
.split(',')
.filter((tag) => tag.trim() !== '') ?? [];
try {
await context.client.post(
`v1/node/${nodeId}/tags`,
session.get('api_key')!,
{
tags,
},
);
return { message: 'Tags updated' };
} catch (error) {
log.debug('api', 'Failed to update tags: %s', error);
return send(
{ message: 'Failed to update tags' },
{
status: 500,
},
);
}
}
case 'register': {
const key = formData.get('mkey')?.toString();
const user = formData.get('user')?.toString();
if (!key) {
return send(
{ message: 'No machine key provided' },
{
status: 400,
},
);
}
if (!user) {
return send(
{ message: 'No user provided' },
{
status: 400,
},
);
}
try {
const qp = new URLSearchParams();
qp.append('user', user);
qp.append('key', key);
const url = `v1/node/register?${qp.toString()}`;
await context.client.post(url, session.get('api_key')!, {
user,
key,
});
return {
success: true,
message: 'Machine registered',
};
} catch {
return send(
{
success: false,
message: 'Failed to register machine',
},
{
status: 500,
},
);
}
}
default: {
return send(
{ message: 'Invalid method' },
{
status: 400,
},
);
}
}
}

View File

@ -9,35 +9,40 @@ import Chip from '~/components/Chip';
import Link from '~/components/Link';
import StatusCircle from '~/components/StatusCircle';
import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import { hs_getConfig } from '~/utils/config/loader';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { hp_getSingleton } from '~server/context/global';
import { menuAction } from './action';
import MenuOptions from './components/menu';
import Routes from './dialogs/routes';
import { machineAction } from './machine-actions';
export async function loader({ request, params }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
export async function loader({
request,
params,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
if (!params.id) {
throw new Error('No machine ID provided');
}
const { mode, config } = hs_getConfig();
let magic: string | undefined;
if (mode !== 'no') {
if (config.dns.magic_dns) {
magic = config.dns.base_domain;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
const [machine, routes, users] = await Promise.all([
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
context.client.get<{ node: Machine }>(
`v1/node/${params.id}`,
session.get('api_key')!,
),
context.client.get<{ routes: Route[] }>(
'v1/routes',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]);
return {
@ -45,12 +50,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
routes: routes.routes.filter((route) => route.node.id === params.id),
users: users.users,
magic,
agent: [...hp_getSingleton('ws_agents').keys()].includes(machine.node.id),
// TODO: Fix agent
agent: false,
// agent: [...(hp_getSingletonUnsafe('ws_agents') ?? []).keys()].includes(
// machine.node.id,
// ),
};
}
export async function action({ request }: ActionFunctionArgs) {
return menuAction(request);
export async function action(request: ActionFunctionArgs) {
return machineAction(request);
}
export default function Page() {
@ -301,14 +310,17 @@ export default function Page() {
<Attribute name="Hostname" value={machine.name} />
<Attribute isCopyable name="Node Key" value={machine.nodeKey} />
<Attribute
suppressHydrationWarning
name="Created"
value={new Date(machine.createdAt).toLocaleString()}
/>
<Attribute
suppressHydrationWarning
name="Last Seen"
value={new Date(machine.lastSeen).toLocaleString()}
/>
<Attribute
suppressHydrationWarning
name="Expiry"
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
/>

View File

@ -1,38 +1,61 @@
import { InfoIcon } from '@primer/octicons-react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import Code from '~/components/Code';
import { ErrorPopup } from '~/components/Error';
import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import type { Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import Tooltip from '~/components/Tooltip';
import { hs_getConfig } from '~/utils/config/loader';
import useAgent from '~/utils/useAgent';
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
import { menuAction } from './action';
import MachineRow from './components/machine';
import MachineRow from './components/machine-row';
import NewMachine from './dialogs/new';
import { machineAction } from './machine-actions';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const user = session.get('user');
if (!user) {
throw new Error('Missing user session. Please log in again.');
}
const check = await context.sessions.check(
request,
Capabilities.read_machines,
);
if (!check) {
// Not authorized to view this page
throw new Error(
'You do not have permission to view this page. Please contact your administrator.',
);
}
const writablePermission = await context.sessions.check(
request,
Capabilities.write_machines,
);
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
const [machines, routes, users] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
),
context.client.get<{ routes: Route[] }>(
'v1/routes',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]);
const context = hp_getConfig();
const { mode, config } = hs_getConfig();
let magic: string | undefined;
if (mode !== 'no') {
if (config.dns.magic_dns) {
magic = config.dns.base_domain;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
@ -41,19 +64,21 @@ export async function loader({ request }: LoaderFunctionArgs) {
routes: routes.routes,
users: users.users,
magic,
server: context.headscale.url,
publicServer: context.headscale.public_url,
agents: [...hp_getSingleton('ws_agents').keys()],
server: context.config.headscale.url,
publicServer: context.config.headscale.public_url,
agents: context.agents?.tailnetIDs(),
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
writable: writablePermission,
subject: user.subject,
};
}
export async function action({ request }: ActionFunctionArgs) {
return menuAction(request);
export async function action(request: ActionFunctionArgs) {
return machineAction(request);
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const { data: stats } = useAgent(data.nodes.map((node) => node.nodeKey));
return (
<>
@ -73,6 +98,7 @@ export default function Page() {
<NewMachine
server={data.publicServer ?? data.server}
users={data.users}
isDisabled={!data.writable}
/>
</div>
<table className="table-auto w-full rounded-lg">
@ -97,7 +123,10 @@ export default function Page() {
) : undefined}
</div>
</th>
<th className="uppercase text-xs font-bold pb-2">Version</th>
{/* We only want to show the version column if there are agents */}
{data.agents !== undefined ? (
<th className="uppercase text-xs font-bold pb-2">Version</th>
) : undefined}
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</tr>
</thead>
@ -116,8 +145,15 @@ export default function Page() {
)}
users={data.users}
magic={data.magic}
stats={stats?.[machine.nodeKey]}
isAgent={data.agents.includes(machine.id)}
// If we pass undefined, the column will not be rendered
// This is useful for when there are no agents configured
isAgent={data.agents?.includes(machine.id)}
stats={data.stats?.[machine.nodeKey]}
isDisabled={
data.writable
? false // If the user has write permissions, they can edit all machines
: machine.user.providerId?.split('/').pop() !== data.subject
}
/>
))}
</tbody>

View File

@ -5,52 +5,48 @@ import { Link as RemixLink } from 'react-router';
import Link from '~/components/Link';
import Select from '~/components/Select';
import TableList from '~/components/TableList';
import type { LoadContext } from '~/server';
import type { PreAuthKey, User } from '~/types';
import { post, pull } from '~/utils/headscale';
import { send } from '~/utils/res';
import { getSession } from '~/utils/sessions.server';
import { hp_getConfig } from '~server/context/global';
import AuthKeyRow from './components/key';
import AddPreAuthKey from './dialogs/new';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
const users = await pull<{ users: User[] }>(
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const users = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('hsApiKey')!,
session.get('api_key')!,
);
const context = hp_getConfig();
const preAuthKeys = await Promise.all(
users.users.map((user) => {
const qp = new URLSearchParams();
qp.set('user', user.name);
users.users
.filter((user) => user.name?.length > 0) // Filter out any invalid users
.map((user) => {
const qp = new URLSearchParams();
qp.set('user', user.name);
return pull<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('hsApiKey')!,
);
}),
return context.client.get<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('api_key')!,
);
}),
);
return {
keys: preAuthKeys.flatMap((keys) => keys.preAuthKeys),
users: users.users,
server: context.headscale.public_url ?? context.headscale.url,
server: context.config.headscale.public_url ?? context.config.headscale.url,
};
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
if (!session.has('hsApiKey')) {
return send(
{ message: 'Unauthorized' },
{
status: 401,
},
);
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const data = await request.formData();
// Expiring a pre-auth key
@ -67,9 +63,9 @@ export async function action({ request }: ActionFunctionArgs) {
);
}
await post<{ preAuthKey: PreAuthKey }>(
await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey/expire',
session.get('hsApiKey')!,
session.get('api_key')!,
{
user: user,
key: key,
@ -101,9 +97,9 @@ export async function action({ request }: ActionFunctionArgs) {
const date = new Date();
date.setDate(date.getDate() + day);
const key = await post<{ preAuthKey: PreAuthKey }>(
const key = await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.get('hsApiKey')!,
session.get('api_key')!,
{
user: user,
ephemeral: ephemeral === 'on',

View File

@ -22,15 +22,19 @@ export default function AuthKeyRow({ authKey, server }: Props) {
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
<Attribute name="Created" value={createdAt} />
<Attribute name="Expiration" value={expiration} />
<Attribute suppressHydrationWarning name="Created" value={createdAt} />
<Attribute
suppressHydrationWarning
name="Expiration"
value={expiration}
/>
<p className="mb-1 mt-4">
To use this key, run the following command on your device:
</p>
<Code className="text-sm">
tailscale up --login-server {server} --authkey {authKey.key}
</Code>
<div className="flex gap-4 items-center">
<div suppressHydrationWarning className="flex gap-4 items-center">
{(authKey.used && !authKey.reusable) ||
new Date(authKey.expiration) < new Date() ? undefined : (
<ExpireKey authKey={authKey} />

View File

@ -1,14 +1,15 @@
import { Building2, House, Key } from 'lucide-react';
import Card from '~/components/Card';
import Link from '~/components/Link';
import type { HeadplaneConfig } from '~server/context/parser';
import type { HeadplaneConfig } from '~/server/config/schema';
import CreateUser from '../dialogs/create-user';
interface Props {
interface ManageBannerProps {
oidc?: NonNullable<HeadplaneConfig['oidc']>;
isDisabled?: boolean;
}
export default function ManageBanner({ oidc }: Props) {
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
return (
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
<div className="flex flex-col md:flex-row">
@ -60,7 +61,7 @@ export default function ManageBanner({ oidc }: Props) {
: 'You can add, remove, and rename users here.'}
</p>
<div className="flex items-center gap-2 mt-4">
<CreateUser />
<CreateUser isDisabled={isDisabled} />
</div>
</div>
</div>

View File

@ -0,0 +1,74 @@
import { Ellipsis } from 'lucide-react';
import { useState } from 'react';
import Menu from '~/components/Menu';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import Delete from '../dialogs/delete-user';
import Reassign from '../dialogs/reassign-user';
import Rename from '../dialogs/rename-user';
interface MenuProps {
user: User & {
headplaneRole: string;
machines: Machine[];
};
}
type Modal = 'rename' | 'delete' | 'reassign' | null;
export default function UserMenu({ user }: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
return (
<>
{modal === 'rename' && (
<Rename
user={user}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'delete' && (
<Delete
user={user}
isOpen={modal === 'delete'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{modal === 'reassign' && (
<Reassign
user={user}
isOpen={modal === 'reassign'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : []}>
<Menu.IconButton
label="Machine Options"
className={cn(
'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
)}
>
<Ellipsis className="h-5" />
</Menu.IconButton>
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
<Menu.Section>
<Menu.Item key="rename">Rename user</Menu.Item>
<Menu.Item key="reassign">Change role</Menu.Item>
<Menu.Item key="delete" textValue="Delete">
<p className="text-red-500 dark:text-red-400">Delete</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
</>
);
}

View File

@ -0,0 +1,95 @@
import { CircleUser } from 'lucide-react';
import StatusCircle from '~/components/StatusCircle';
import { Machine, User } from '~/types';
import cn from '~/utils/cn';
import MenuOptions from './menu';
interface UserRowProps {
role: string;
user: User & { machines: Machine[] };
}
export default function UserRow({ user, role }: UserRowProps) {
const isOnline = user.machines.some((machine) => machine.online);
const lastSeen = user.machines.reduce(
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
0,
);
return (
<tr
key={user.id}
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
>
<td className="pl-0.5 py-2">
<div className="flex items-center">
{user.profilePicUrl ? (
<img
src={user.profilePicUrl}
alt={user.name}
className="w-10 h-10 rounded-full"
/>
) : (
<CircleUser className="w-10 h-10" />
)}
<div className="ml-4">
<p className={cn('font-semibold leading-snug')}>{user.name}</p>
<p className="text-sm opacity-50">{user.email}</p>
</div>
</div>
</td>
<td className="pl-0.5 py-2">
<p>{mapRoleToName(role)}</p>
</td>
<td className="pl-0.5 py-2">
<p
suppressHydrationWarning
className="text-sm text-headplane-600 dark:text-headplane-300"
>
{new Date(user.createdAt).toLocaleDateString()}
</p>
</td>
<td className="pl-0.5 py-2">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-headplane-600 dark:text-headplane-300',
)}
>
<StatusCircle isOnline={isOnline} className="w-4 h-4" />
<p suppressHydrationWarning>
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions user={{ ...user, headplaneRole: role }} />
</td>
</tr>
);
}
function mapRoleToName(role: string) {
switch (role) {
case 'no-oidc':
return <p className="opacity-50">Unmanaged</p>;
case 'invalid-oidc':
return <p className="opacity-50">Invalid</p>;
case 'no-role':
return <p className="opacity-50">Unregistered</p>;
case 'owner':
return 'Owner';
case 'admin':
return 'Admin';
case 'network_admin':
return 'Network Admin';
case 'it_admin':
return 'IT Admin';
case 'auditor':
return 'Auditor';
case 'member':
return 'Member';
default:
return 'Unknown';
}
}

View File

@ -1,11 +1,15 @@
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface CreateUserProps {
isDisabled?: boolean;
}
// TODO: Support image upload for user avatars
export default function CreateUser() {
export default function CreateUser({ isDisabled }: CreateUserProps) {
return (
<Dialog>
<Dialog.Button>Add a new user</Dialog.Button>
<Dialog.Button isDisabled={isDisabled}>Add a new user</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add a new user</Dialog.Title>
<Dialog.Text className="mb-6">

View File

@ -1,27 +1,38 @@
import { X } from 'lucide-react';
import Dialog from '~/components/Dialog';
import { User } from '~/types';
import { Machine, User } from '~/types';
interface Props {
user: User;
interface DeleteProps {
user: User & { machines: Machine[] };
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
// TODO: Warn that OIDC users will be recreated on next login
export default function DeleteUser({ user }: Props) {
export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
const name =
(user.displayName?.length ?? 0) > 0 ? user.displayName : user.name;
return (
<Dialog>
<Dialog.IconButton label={`Delete ${name}`}>
<X className="p-0.5" />
</Dialog.IconButton>
<Dialog.Panel>
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel
variant={user.machines.length > 0 ? 'unactionable' : 'normal'}
>
<Dialog.Title>Delete {name}?</Dialog.Title>
<Dialog.Text className="mb-6">
Are you sure you want to delete {name}? A deleted user cannot be
recovered.
</Dialog.Text>
{user.machines.length > 0 ? (
<Dialog.Text className="mb-6">
Users cannot be deleted if they have machines. Please delete or
re-assign their machines to other users before proceeding.
</Dialog.Text>
) : (
<Dialog.Text className="mb-6">
Deleted users cannot be recovered.
{user.provider === 'oidc' && (
<p className="mt-4 text-sm text-headplane-600 dark:text-headplane-300">
Since this user is authenticated via an external provider, they
will be recreated if they sign in again.
</p>
)}
</Dialog.Text>
)}
<input type="hidden" name="action_id" value="delete_user" />
<input type="hidden" name="user_id" value={user.id} />
</Dialog.Panel>

View File

@ -0,0 +1,103 @@
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import RadioGroup from '~/components/RadioGroup';
import { Roles } from '~/server/web/roles';
import { User } from '~/types';
interface ReassignProps {
user: User & { headplaneRole: string };
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function ReassignUser({
user,
isOpen,
setIsOpen,
}: ReassignProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
>
<Dialog.Title>Change role for {user.name}?</Dialog.Title>
<Dialog.Text className="mb-6">
Most roles are carried straight from Tailscale. However, keep in mind
that I have not fully implemented permissions yet and some things may
be accessible to everyone. The only fully completed role is Member.{' '}
<Link
to="https://tailscale.com/kb/1138/user-roles"
name="Tailscale User Roles documentation"
>
Learn More
</Link>
</Dialog.Text>
{user.headplaneRole === 'owner' ? (
<Notice>The Tailnet owner cannot be reassigned.</Notice>
) : (
<>
<input type="hidden" name="action_id" value="reassign_user" />
<input type="hidden" name="user_id" value={user.id} />
<RadioGroup
isRequired
name="new_role"
label="Role"
className="gap-4"
defaultValue={user.headplaneRole}
>
{Object.keys(Roles)
.filter((role) => role !== 'owner')
.map((role) => {
const { name, desc } = mapRoleToName(role);
return (
<RadioGroup.Radio key={role} value={role} label={name}>
<div className="block">
<p className="font-bold">{name}</p>
<p className="opacity-70">{desc}</p>
</div>
</RadioGroup.Radio>
);
})}
</RadioGroup>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
function mapRoleToName(role: string) {
switch (role) {
case 'admin':
return {
name: 'Admin',
desc: 'Can view the admin console, manage network, machine, and user settings.',
};
case 'network_admin':
return {
name: 'Network Admin',
desc: 'Can view the admin console and manage ACLs and network settings. Cannot manage machines or users.',
};
case 'it_admin':
return {
name: 'IT Admin',
desc: 'Can view the admin console and manage machines and users. Cannot manage ACLs or network settings.',
};
case 'auditor':
return {
name: 'Auditor',
desc: 'Can view the admin console.',
};
case 'member':
return {
name: 'Member',
desc: 'Cannot view the admin console.',
};
default:
return {
name: 'Unknown',
desc: 'Unknown',
};
}
}

View File

@ -1,19 +1,17 @@
import { Pencil } from 'lucide-react';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import { User } from '~/types';
interface Props {
interface RenameProps {
user: User;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
// TODO: Server side validation before submitting
export default function RenameUser({ user }: Props) {
export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
return (
<Dialog>
<Dialog.IconButton label={`Rename ${user.name}`}>
<Pencil className="p-1" />
</Dialog.IconButton>
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Rename {user.name}?</Dialog.Title>
<Dialog.Text className="mb-6">

View File

@ -0,0 +1,16 @@
import { LoaderFunctionArgs, redirect } from 'react-router';
import { LoadContext } from '~/server';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const user = session.get('user');
if (!user) {
return redirect('/login');
}
context.sessions.overrideOnboarding(user.subject, true);
return redirect('/machines');
}

View File

@ -0,0 +1,347 @@
import { ArrowRight } from 'lucide-react';
import { useEffect } from 'react';
import { GrApple } from 'react-icons/gr';
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 Card from '~/components/Card';
import Link from '~/components/Link';
import Options from '~/components/Options';
import StatusCircle from '~/components/StatusCircle';
import { LoadContext } from '~/server';
import { Machine } from '~/types';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
import log from '~/utils/log';
import toast from '~/utils/toast';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
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
// We need to convert this to a known value to return it to the client so we can
// automatically tab to the correct download button.
const userAgent = request.headers.get('user-agent');
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
let osValue = 'linux';
switch (os?.[0]) {
case 'Windows':
osValue = 'windows';
break;
case 'Mac OS X':
osValue = 'macos';
break;
case 'iPhone':
case 'iPad':
osValue = 'ios';
break;
case 'Android':
osValue = 'android';
break;
default:
osValue = 'linux';
break;
}
let firstMachine: Machine | undefined = undefined;
try {
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
);
const node = nodes.find((n) => {
if (n.user.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 = n.user.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
if (subject !== sessionUser.subject) {
return false;
}
return true;
});
firstMachine = node;
} catch (e) {
// If we cannot lookup nodes, we cannot proceed
log.debug('api', 'Failed to lookup nodes %o', e);
}
return {
user,
osValue,
firstMachine,
};
}
export default function Page() {
const { user, osValue, firstMachine } = useLoaderData<typeof loader>();
const { pause, resume } = useLiveData();
useEffect(() => {
if (firstMachine) {
pause();
} else {
resume();
}
}, [firstMachine]);
const subject = user.email ? (
<>
as <strong>{user.email}</strong>
</>
) : (
'with your OIDC provider'
);
return (
<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">
<Card variant="flat" className="max-w-lg">
<Card.Title className="mb-8">
Welcome!
<br />
Let's get set up
</Card.Title>
<Card.Text>
Install Tailscale and sign in {subject}. Once you sign in on a
device, it will be automatically added to your Headscale network.
</Card.Text>
<Options
defaultSelectedKey={osValue}
label="Download Selector"
className="my-4"
>
<Options.Item
key="linux"
title={
<div className="flex items-center gap-1">
<PiTerminalFill className="ml-1 w-4" />
<span>Linux</span>
</div>
}
>
<Button
className="flex text-md font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
'curl -fsSL https://tailscale.com/install.sh | sh',
);
toast('Copied to clipboard');
}}
>
curl -fsSL https://tailscale.com/install.sh | sh
</Button>
<p className="text-xs mt-1 text-headplane-600 dark:text-headplane-300 text-center">
Click this button to copy the command.{' '}
<Link
name="Linux installation script"
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
>
View script source
</Link>
</p>
</Options.Item>
<Options.Item
key="windows"
title={
<div className="flex items-center gap-1">
<PiWindowsLogoFill className="ml-1 w-4" />
<span>Windows</span>
</div>
}
>
<a
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
aria-label="Download for Windows"
target="_blank"
rel="noreferrer"
>
<Button variant="heavy" className="my-4 w-full">
Download for Windows
</Button>
</a>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
Requires Windows 10 or later.
</p>
</Options.Item>
<Options.Item
key="macos"
title={
<div className="flex items-center gap-1">
<ImFinder className="ml-1 w-4" />
<span>macOS</span>
</div>
}
>
<a
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
aria-label="Download for macOS"
target="_blank"
rel="noreferrer"
>
<Button variant="heavy" className="my-4 w-full">
Download for macOS
</Button>
</a>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
Requires macOS Big Sur 11.0 or later.
<br />
You can also download Tailscale on the{' '}
<Link
name="macOS App Store"
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
>
macOS App Store
</Link>
{'.'}
</p>
</Options.Item>
<Options.Item
key="ios"
title={
<div className="flex items-center gap-1">
<GrApple className="ml-1 w-4" />
<span>iOS</span>
</div>
}
>
<a
href="https://apps.apple.com/us/app/tailscale/id1470499037"
aria-label="Download for iOS"
target="_blank"
rel="noreferrer"
>
<Button variant="heavy" className="my-4 w-full">
Download for iOS
</Button>
</a>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
Requires iOS 15 or later.
</p>
</Options.Item>
<Options.Item
key="android"
title={
<div className="flex items-center gap-1">
<MdAndroid className="ml-1 w-4" />
<span>Android</span>
</div>
}
>
<a
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
aria-label="Download for Android"
target="_blank"
rel="noreferrer"
>
<Button variant="heavy" className="my-4 w-full">
Download for Android
</Button>
</a>
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
Requires Android 8 or later.
</p>
</Options.Item>
</Options>
</Card>
<Card variant="flat">
{firstMachine ? (
<div className="flex flex-col justify-between h-full">
<Card.Title className="mb-8">
Success!
<br />
We found your first device
</Card.Title>
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4">
<div className="flex items-start gap-4">
<StatusCircle
isOnline={firstMachine.online}
className="size-6 mt-3"
/>
<div>
<p className="font-semibold leading-snug">
{firstMachine.givenName}
</p>
<p className="text-sm font-mono opacity-50">
{firstMachine.name}
</p>
<div className="mt-6">
<p className="text-sm font-semibold">IP Addresses</p>
{firstMachine.ipAddresses.map((ip) => (
<p key={ip} className="text-xs font-mono opacity-50">
{ip}
</p>
))}
</div>
</div>
</div>
</div>
<NavLink to="/">
<Button variant="heavy" className="w-full">
Continue
</Button>
</NavLink>
</div>
) : (
<div className="flex flex-col items-center justify-center gap-4 h-full">
<span className="relative flex size-4">
<span
className={cn(
'absolute inline-flex h-full w-full',
'rounded-full opacity-75 animate-ping',
'bg-headplane-500',
)}
/>
<span
className={cn(
'relative inline-flex size-4 rounded-full',
'bg-headplane-400',
)}
/>
</span>
<p className="font-lg">Waiting for your first device...</p>
</div>
)}
</Card>
<NavLink to="/onboarding/skip" className="col-span-2 w-max mx-auto">
<Button className="flex items-center gap-1">
I already know what I'm doing
<ArrowRight className="p-1" />
</Button>
</NavLink>
</div>
</div>
);
}

View File

@ -1,32 +1,42 @@
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
import { PersonIcon } from '@primer/octicons-react';
import { useEffect, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData, useSubmit } from 'react-router';
import { ClientOnly } from 'remix-utils/client-only';
import Attribute from '~/components/Attribute';
import Card from '~/components/Card';
import { ErrorPopup } from '~/components/Error';
import StatusCircle from '~/components/StatusCircle';
import type { Machine, User } from '~/types';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { Machine, User } from '~/types';
import cn from '~/utils/cn';
import { pull } from '~/utils/headscale';
import { getSession } from '~/utils/sessions.server';
import { hs_getConfig } from '~/utils/config/loader';
import type { AppContext } from '~server/context/app';
import { hp_getConfig } from '~server/context/global';
import ManageBanner from './components/manage-banner';
import DeleteUser from './dialogs/delete-user';
import RenameUser from './dialogs/rename-user';
import UserRow from './components/user-row';
import { userAction } from './user-actions';
export async function loader({ request }: LoaderFunctionArgs<AppContext>) {
const session = await getSession(request.headers.get('Cookie'));
interface UserMachine extends User {
machines: Machine[];
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) {
// Not authorized to view this page
throw new Error(
'You do not have permission to view this page. Please contact your administrator.',
);
}
const writablePermission = await context.sessions.check(
request,
Capabilities.write_users,
);
const [machines, apiUsers] = await Promise.all([
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]);
const users = apiUsers.users.map((user) => ({
@ -34,18 +44,42 @@ export async function loader({ request }: LoaderFunctionArgs<AppContext>) {
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
}));
const { oidc } = hp_getConfig();
const { mode, config } = hs_getConfig();
let magic: string | undefined;
const roles = users
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => {
if (user.provider !== 'oidc') {
return 'no-oidc';
}
if (mode !== 'no') {
if (config.dns.magic_dns) {
magic = config.dns.base_domain;
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);
return role ?? '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;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
return {
oidc,
writable: writablePermission, // whether the user can write to the API
oidc: context.config.oidc,
roles,
magic,
users,
};
@ -74,163 +108,33 @@ export default function Page() {
Manage the users in your network and their permissions. Tip: You can
drag machines between users to change ownership.
</p>
<ManageBanner oidc={data.oidc} />
<ClientOnly fallback={<Users users={users} />}>
{() => (
<InteractiveUsers
users={users}
setUsers={setUsers}
magic={data.magic}
/>
)}
</ClientOnly>
<ManageBanner oidc={data.oidc} isDisabled={!data.writable} />
<table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th className="uppercase text-xs font-bold pb-2">User</th>
<th className="uppercase text-xs font-bold pb-2">Role</th>
<th className="uppercase text-xs font-bold pb-2">Created At</th>
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{users
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => (
<UserRow
key={user.id}
user={user}
role={data.roles[users.indexOf(user)]}
/>
))}
</tbody>
</table>
</>
);
}
type UserMachine = User & { machines: Machine[] };
interface UserProps {
users: UserMachine[];
setUsers?: (users: UserMachine[]) => void;
magic?: string;
}
function Users({ users, magic }: UserProps) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map((user) => (
<UserCard key={user.id} user={user} magic={magic} />
))}
</div>
);
}
function InteractiveUsers({ users, setUsers, magic }: UserProps) {
const submit = useSubmit();
return (
<DndContext
onDragEnd={(event) => {
const { over, active } = event;
if (!over) {
return;
}
// Update the UI optimistically
const newUsers = new Array<UserMachine>();
const reference = active.data as DataRef<Machine>;
if (!reference.current) {
return;
}
// Ignore if the user is unchanged
if (reference.current.user.name === over.id) {
return;
}
for (const user of users) {
newUsers.push({
...user,
machines:
over.id === user.name
? [...user.machines, reference.current]
: user.machines.filter((m) => m.id !== active.id),
});
}
setUsers?.(newUsers);
const data = new FormData();
data.append('action_id', 'change_owner');
data.append('user_id', over.id.toString());
data.append('node_id', reference.current.id);
submit(data, {
method: 'POST',
});
}}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
{users.map((user) => (
<UserCard key={user.id} user={user} magic={magic} />
))}
</div>
</DndContext>
);
}
function MachineChip({ machine }: { readonly machine: Machine }) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({
id: machine.id,
data: machine,
});
return (
<div
ref={setNodeRef}
className={cn(
'flex items-center w-full gap-2 py-1',
'hover:bg-headplane-50 dark:hover:bg-headplane-950 rounded-xl',
)}
style={{
transform: transform
? `translate3d(${transform.x.toString()}px, ${transform.y.toString()}px, 0)`
: undefined,
}}
{...listeners}
{...attributes}
>
<StatusCircle isOnline={machine.online} className="px-1 h-4 w-fit" />
<Attribute
name={machine.givenName}
link={`machines/${machine.id}`}
value={machine.ipAddresses[0]}
/>
</div>
);
}
interface CardProps {
user: UserMachine;
magic?: string;
}
function UserCard({ user, magic }: CardProps) {
const { isOver, setNodeRef } = useDroppable({
id: user.name,
});
return (
<div ref={setNodeRef}>
<Card
variant="flat"
className={cn(
'max-w-full w-full overflow-visible h-full',
isOver ? 'bg-headplane-100 dark:bg-headplane-800' : '',
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<PersonIcon className="w-6 h-6" />
<span className="text-lg font-mono">{user.name}</span>
</div>
<div className="flex items-center gap-2">
<RenameUser user={user} />
{user.machines.length === 0 ? (
<DeleteUser user={user} />
) : undefined}
</div>
</div>
<div className="mt-4">
{user.machines.map((machine) => (
<MachineChip key={machine.id} machine={machine} />
))}
</div>
</Card>
</div>
);
}
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}

View File

@ -1,81 +1,141 @@
import { ActionFunctionArgs, data } from 'react-router';
import { del, post } from '~/utils/headscale';
import { auth } from '~/utils/sessions.server';
import { ActionFunctionArgs, Session, data } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities, Roles } from '~/server/web/roles';
import { AuthSession } from '~/server/web/sessions';
import { User } from '~/types';
import { data400, data403 } from '~/utils/res';
export async function userAction({ request }: ActionFunctionArgs) {
const session = await auth(request);
if (!session) {
return data({ success: false }, 401);
export async function userAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.write_users);
if (!check) {
throw data403('You do not have permission to update users');
}
const apiKey = session.get('api_key')!;
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
return data({ success: false }, 400);
}
const apiKey = session.get('hsApiKey');
if (!apiKey) {
return data({ success: false }, 401);
throw data400('Missing `action_id` in the form data.');
}
switch (action) {
case 'create_user':
return createUser(formData, apiKey);
return createUser(formData, apiKey, context);
case 'delete_user':
return deleteUser(formData, apiKey);
return deleteUser(formData, apiKey, context);
case 'rename_user':
return renameUser(formData, apiKey);
case 'change_owner':
return changeOwner(formData, apiKey);
return renameUser(formData, apiKey, context);
case 'reassign_user':
return reassignUser(formData, apiKey, context, session);
default:
return data({ success: false }, 400);
throw data400('Invalid `action_id` provided.');
}
}
async function createUser(formData: FormData, apiKey: string) {
async function createUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const name = formData.get('username')?.toString();
const displayName = formData.get('display_name')?.toString();
const email = formData.get('email')?.toString();
if (!name) {
return data({ success: false }, 400);
throw data400('Missing `username` in the form data.');
}
await post('v1/user', apiKey, {
await context.client.post('v1/user', apiKey, {
name,
displayName,
email,
});
}
async function deleteUser(formData: FormData, apiKey: string) {
async function deleteUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const userId = formData.get('user_id')?.toString();
if (!userId) {
return data({ success: false }, 400);
throw data400('Missing `user_id` in the form data.');
}
await del(`v1/user/${userId}`, apiKey);
await context.client.delete(`v1/user/${userId}`, apiKey);
}
async function renameUser(formData: FormData, apiKey: string) {
async function renameUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const userId = formData.get('user_id')?.toString();
const newName = formData.get('new_name')?.toString();
if (!userId || !newName) {
return data({ success: false }, 400);
}
await post(`v1/user/${userId}/rename/${newName}`, apiKey);
}
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
apiKey,
);
async function changeOwner(formData: FormData, apiKey: string) {
const userId = formData.get('user_id')?.toString();
const nodeId = formData.get('node_id')?.toString();
if (!userId || !nodeId) {
return data({ success: false }, 400);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data400(`No user found with id: ${userId}`);
}
await post(`v1/node/${nodeId}/user`, apiKey, {
user: userId,
});
if (user.provider === 'oidc') {
// OIDC users cannot be renamed via this endpoint, return an error
throw data403('Users managed by OIDC cannot be renamed');
}
await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey);
}
async function reassignUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const userId = formData.get('user_id')?.toString();
const newRole = formData.get('new_role')?.toString();
if (!userId || !newRole) {
throw data400('Missing `user_id` or `new_role` in the form data.');
}
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
apiKey,
);
const user = users.find((user) => user.id === userId);
if (!user?.providerId) {
throw data400('Specified user is not an OIDC user');
}
// 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) {
throw data400(
'Malformed `providerId` for the specified user. Cannot find subject.',
);
}
const result = await context.sessions.reassignSubject(
subject,
newRole as keyof typeof Roles,
);
if (!result) {
return data({ success: false }, 403);
}
return data({ success: true });
}

View File

@ -1,14 +1,8 @@
import { healthcheck } from '~/utils/headscale';
import log from '~server/utils/log';
export async function loader() {
let healthy = false;
try {
healthy = await healthcheck();
} catch (error) {
log.debug('APIC', 'Healthcheck failed %o', error);
}
import { LoaderFunctionArgs } from 'react-router';
import type { LoadContext } from '~/server';
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck();
return new Response(JSON.stringify({ status: healthy ? 'OK' : 'ERROR' }), {
status: healthy ? 200 : 500,
headers: {

29
app/server/README.md Normal file
View File

@ -0,0 +1,29 @@
# Headplane Server
This code is responsible for all code that is necessary *before* any
web server is started. It is the only part of the code that contains
many side-effects (in this case, importing a module may run code).
# Hierarchy
```
server
├── index.ts: Loads everything and starts the web server.
├── config/
│ ├── integration/
│ │ ├── abstract.ts: Defines the abstract class for integrations.
│ │ ├── docker.ts: Contains the Docker integration.
│ │ ├── index.ts: Determines the correct integration to use (if any).
│ │ ├── kubernetes.ts: Contains the Kubernetes integration.
│ │ ├── proc.ts: Contains the Proc integration.
│ ├── env.ts: Checks the environment variables for custom overrides.
│ ├── loader.ts: Checks the configuration file and coalesces with ENV.
│ ├── schema.ts: Defines the schema for the Headplane configuration.
├── headscale/
│ ├── api-client.ts: Creates the HTTP client that talks to the Headscale API.
│ ├── api-error.ts: Contains the ResponseError definition.
│ ├── config-loader.ts: Loads the Headscale configuration (if available).
│ ├── config-schema.ts: Defines the schema for the Headscale configuration.
├── web/
│ ├── agent.ts: Handles setting up the agent WebSocket if needed.
│ ├── oidc.ts: Loads and validates an OIDC configuration (if available).
│ ├── roles.ts: Contains information about authentication permissions.
│ ├── sessions.ts: Initializes the session store and methods to manage it.

76
app/server/config/env.ts Normal file
View File

@ -0,0 +1,76 @@
import { exit } from 'node:process';
import { type } from 'arktype';
import log from '~/utils/log';
// Custom type for boolean environment variables, allowing for values like
// 1, true, yes, and on to count as a truthy value.
const booleanEnv = type('string | undefined').pipe((v) => {
return ['1', 'true', 'yes', 'on'].includes(v?.toLowerCase() ?? '');
});
export const envVariables = {
debugLog: 'HEADPLANE_DEBUG_LOG',
envOverrides: 'HEADPLANE_LOAD_ENV_OVERRIDES',
configPath: 'HEADPLANE_CONFIG_PATH',
} as const;
export function configureLogger(env: string | undefined) {
const result = booleanEnv(env);
if (result instanceof type.errors) {
log.error(
'config',
'HEADPLANE_DEBUG_LOG value is invalid: %s',
result.summary,
);
log.info('config', 'Using a default value: false');
log.debug = () => {}; // Disable debug logging if the value is invalid
log.debugEnabled = false;
return;
}
if (result === false) {
log.debug = () => {}; // Disable debug logging if the value is false
log.debugEnabled = false;
return;
}
log.debug('config', 'Debug logging has been enabled');
log.debug('config', 'It is recommended this be disabled in production');
}
export interface EnvOverrides {
loadEnv: boolean;
path: string;
}
export function configureConfig(overrides: {
loadEnv: string | undefined;
path: string | undefined;
}): EnvOverrides {
const loadResult = booleanEnv(overrides.loadEnv);
if (loadResult instanceof type.errors) {
log.error(
'config',
'HEADPLANE_LOAD_ENV_OVERRIDES value is invalid: %s',
loadResult.summary,
);
exit(1);
}
const pathResult = type('string | undefined')(overrides.path);
if (pathResult instanceof type.errors) {
log.error(
'config',
'HEADPLANE_CONFIG_PATH value is invalid: %s',
pathResult.summary,
);
exit(1);
}
return {
loadEnv: loadResult,
path: pathResult ?? '/etc/headplane/config.yaml',
};
}

View File

@ -1,3 +1,5 @@
import type { ApiClient } from '~/server/headscale/api-client';
export abstract class Integration<T> {
protected context: NonNullable<T>;
constructor(context: T) {
@ -9,6 +11,6 @@ export abstract class Integration<T> {
}
abstract isAvailable(): Promise<boolean> | boolean;
abstract onConfigChange(): Promise<void> | void;
abstract onConfigChange(client: ApiClient): Promise<void> | void;
abstract get name(): string;
}

View File

@ -0,0 +1,229 @@
import { constants, access } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import { Client } from 'undici';
import { ApiClient } from '~/server/headscale/api-client';
import log from '~/utils/log';
import type { HeadplaneConfig } from '../schema';
import { Integration } from './abstract';
interface DockerContainer {
Id: string;
Names: string[];
}
type T = NonNullable<HeadplaneConfig['integration']>['docker'];
export default class DockerIntegration extends Integration<T> {
private maxAttempts = 10;
private client: Client | undefined;
get name() {
return 'Docker';
}
async getContainerName(label: string, value: string): Promise<string> {
if (!this.client) {
throw new Error('Docker client is not initialized');
}
const filters = encodeURIComponent(
JSON.stringify({
label: [`${label}=${value}`],
}),
);
const { body } = await this.client.request({
method: 'GET',
path: `/containers/json?filters=${filters}`,
});
const containers: DockerContainer[] =
(await body.json()) as DockerContainer[];
if (containers.length > 1) {
throw new Error(
`Found multiple Docker containers matching label ${label}=${value}. Please specify a container name.`,
);
}
if (containers.length === 0) {
throw new Error(
`No Docker containers found matching label: ${label}=${value}`,
);
}
log.info(
'config',
'Found Docker container matching label: %s=%s',
label,
value,
);
return containers[0].Id;
}
async isAvailable() {
// Perform a basic check to see if any of the required properties are set
if (
this.context.container_name.length === 0 &&
!this.context.container_label
) {
log.error('config', 'Docker container name and label are both empty');
return false;
}
if (
this.context.container_name.length > 0 &&
!this.context.container_label
) {
log.error(
'config',
'Docker container name and label are mutually exclusive',
);
return false;
}
// Verify that Docker socket is reachable
let url: URL | undefined;
try {
url = new URL(this.context.socket);
} catch {
log.error(
'config',
'Invalid Docker socket path: %s',
this.context.socket,
);
return false;
}
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
log.error('config', 'Invalid Docker socket protocol: %s', url.protocol);
return false;
}
// The API is available as an HTTP endpoint and this
// will simplify the fetching logic in undici
if (url.protocol === 'tcp:') {
// Apparently setting url.protocol doesn't work anymore?
const fetchU = url.href.replace(url.protocol, 'http:');
try {
log.info('config', 'Checking API: %s', fetchU);
await fetch(new URL('/v1.30/version', fetchU).href);
} catch (error) {
log.error('config', 'Failed to connect to Docker API: %s', error);
log.debug('config', 'Connection error: %o', error);
return false;
}
this.client = new Client(fetchU);
}
// Check if the socket is accessible
if (url.protocol === 'unix:') {
try {
log.info('config', 'Checking socket: %s', url.pathname);
await access(url.pathname, constants.R_OK);
} catch (error) {
log.error('config', 'Failed to access Docker socket: %s', url.pathname);
log.debug('config', 'Access error: %o', error);
return false;
}
this.client = new Client('http://localhost', {
socketPath: url.pathname,
});
}
if (this.client === undefined) {
log.error('config', 'Failed to create Docker client');
return false;
}
if (this.context.container_name.length === 0) {
try {
if (this.context.container_label === undefined) {
log.error('config', 'Docker container label is not defined');
return false;
}
const containerName = await this.getContainerName(
this.context.container_label.name,
this.context.container_label.value,
);
if (containerName.length === 0) {
log.error(
'config',
'No Docker containers found matching label: %s=%s',
this.context.container_label.name,
this.context.container_label.value,
);
return false;
}
this.context.container_name = containerName;
} catch (error) {
log.error('config', 'Failed to get Docker container name: %s', error);
return false;
}
}
log.info('config', 'Using container: %s', this.context.container_name);
return this.client !== undefined;
}
async onConfigChange(client: ApiClient) {
if (!this.client) {
return;
}
log.info('config', 'Restarting Headscale via Docker');
let attempts = 0;
while (attempts <= this.maxAttempts) {
log.debug(
'config',
'Restarting container: %s (attempt %d)',
this.context.container_name,
attempts,
);
const response = await this.client.request({
method: 'POST',
path: `/v1.30/containers/${this.context.container_name}/restart`,
});
if (response.statusCode !== 204) {
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
continue;
}
const stringCode = response.statusCode.toString();
const body = await response.body.text();
throw new Error(`API request failed: ${stringCode} ${body}`);
}
break;
}
attempts = 0;
while (attempts <= this.maxAttempts) {
try {
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck();
if (status === false) {
throw new Error('Headscale is not running');
}
log.info('config', 'Headscale is up and running');
return;
} catch (error) {
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
continue;
}
log.error(
'config',
'Missed restart deadline for %s',
this.context.container_name,
);
return;
}
}
}
}

View File

@ -0,0 +1,62 @@
import { HeadplaneConfig } from '~/server/config/schema';
import log from '~/utils/log';
import dockerIntegration from './docker';
import kubernetesIntegration from './kubernetes';
import procIntegration from './proc';
export async function loadIntegration(context: HeadplaneConfig['integration']) {
const integration = getIntegration(context);
if (!integration) {
return;
}
try {
const res = await integration.isAvailable();
if (!res) {
log.error('config', 'Integration %s is not available', integration);
return;
}
} catch (error) {
log.error(
'config',
'Failed to load integration %s: %s',
integration,
error,
);
log.debug('config', 'Loading error: %o', error);
return;
}
return integration;
}
function getIntegration(integration: HeadplaneConfig['integration']) {
const docker = integration?.docker;
const k8s = integration?.kubernetes;
const proc = integration?.proc;
if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) {
log.debug('config', 'No integrations enabled');
return;
}
if (docker?.enabled && k8s?.enabled && proc?.enabled) {
log.error('config', 'Multiple integrations enabled, please pick one only');
return;
}
if (docker?.enabled) {
log.info('config', 'Using Docker integration');
return new dockerIntegration(integration?.docker);
}
if (k8s?.enabled) {
log.info('config', 'Using Kubernetes integration');
return new kubernetesIntegration(integration?.kubernetes);
}
if (proc?.enabled) {
log.info('config', 'Using Proc integration');
return new procIntegration(integration?.proc);
}
}

View File

@ -4,9 +4,9 @@ import { join, resolve } from 'node:path';
import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
import { HeadscaleError, healthcheck } from '~/utils/headscale';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
import { ApiClient } from '~/server/headscale/api-client';
import log from '~/utils/log';
import { HeadplaneConfig } from '../schema';
import { Integration } from './abstract';
// TODO: Upgrade to the new CoreV1Api from @kubernetes/client-node
@ -21,16 +21,16 @@ export default class KubernetesIntegration extends Integration<T> {
async isAvailable() {
if (platform() !== 'linux') {
log.error('INTG', 'Kubernetes is only available on Linux');
log.error('config', 'Kubernetes is only available on Linux');
return false;
}
const svcRoot = Config.SERVICEACCOUNT_ROOT;
try {
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot);
log.debug('config', 'Checking Kubernetes service account at %s', svcRoot);
const files = await readdir(svcRoot);
if (files.length === 0) {
log.error('INTG', 'Kubernetes service account not found');
log.error('config', 'Kubernetes service account not found');
return false;
}
@ -41,17 +41,17 @@ export default class KubernetesIntegration extends Integration<T> {
Config.SERVICEACCOUNT_NAMESPACE_PATH,
];
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '));
log.debug('config', 'Looking for %s', expectedFiles.join(', '));
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
log.error('INTG', 'Malformed Kubernetes service account');
log.error('config', 'Malformed Kubernetes service account');
return false;
}
} catch (error) {
log.error('INTG', 'Failed to access %s: %s', svcRoot, error);
log.error('config', 'Failed to access %s: %s', svcRoot, error);
return false;
}
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot);
log.debug('config', 'Reading Kubernetes service account at %s', svcRoot);
const namespace = await readFile(
Config.SERVICEACCOUNT_NAMESPACE_PATH,
'utf8',
@ -59,39 +59,39 @@ export default class KubernetesIntegration extends Integration<T> {
// Some very ugly nesting but it's necessary
if (this.context.validate_manifest === false) {
log.warn('INTG', 'Skipping strict Pod status check');
log.warn('config', 'Skipping strict Pod status check');
} else {
const pod = this.context.pod_name;
if (!pod) {
log.error('INTG', 'Missing POD_NAME variable');
log.error('config', 'Missing POD_NAME variable');
return false;
}
if (pod.trim().length === 0) {
log.error('INTG', 'Pod name is empty');
log.error('config', 'Pod name is empty');
return false;
}
log.debug(
'INTG',
'config',
'Checking Kubernetes pod %s in namespace %s',
pod,
namespace,
);
try {
log.debug('INTG', 'Attempgin to get cluster KubeConfig');
log.debug('config', 'Attempgin to get cluster KubeConfig');
const kc = new KubeConfig();
kc.loadFromCluster();
const cluster = kc.getCurrentCluster();
if (!cluster) {
log.error('INTG', 'Malformed kubeconfig');
log.error('config', 'Malformed kubeconfig');
return false;
}
log.info(
'INTG',
'config',
'Service account connected to %s (%s)',
cluster.name,
cluster.server,
@ -100,14 +100,14 @@ export default class KubernetesIntegration extends Integration<T> {
const kCoreV1Api = kc.makeApiClient(CoreV1Api);
log.info(
'INTG',
'config',
'Checking pod %s in namespace %s (%s)',
pod,
namespace,
kCoreV1Api.basePath,
);
log.debug('INTG', 'Reading pod info for %s', pod);
log.debug('config', 'Reading pod info for %s', pod);
const { response, body } = await kCoreV1Api.readNamespacedPod(
pod,
namespace,
@ -115,36 +115,39 @@ export default class KubernetesIntegration extends Integration<T> {
if (response.statusCode !== 200) {
log.error(
'INTG',
'config',
'Failed to read pod info: http %d',
response.statusCode,
);
return false;
}
log.debug('INTG', 'Got pod info: %o', body.spec);
log.debug('config', 'Got pod info: %o', body.spec);
const shared = body.spec?.shareProcessNamespace;
if (shared === undefined) {
log.error('INTG', 'Pod does not have spec.shareProcessNamespace set');
log.error(
'config',
'Pod does not have spec.shareProcessNamespace set',
);
return false;
}
if (!shared) {
log.error(
'INTG',
'config',
'Pod has set but disabled spec.shareProcessNamespace',
);
return false;
}
log.info('INTG', 'Pod %s enabled shared processes', pod);
log.info('config', 'Pod %s enabled shared processes', pod);
} catch (error) {
log.error('INTG', 'Failed to read pod info: %s', error);
log.error('config', 'Failed to read pod info: %s', error);
return false;
}
}
log.debug('INTG', 'Looking for namespaced process in /proc');
log.debug('config', 'Looking for namespaced process in /proc');
const dir = resolve('/proc');
try {
const subdirs = await readdir(dir);
@ -157,13 +160,13 @@ export default class KubernetesIntegration extends Integration<T> {
const path = join('/proc', dir, 'cmdline');
try {
log.debug('INTG', 'Reading %s', path);
log.debug('config', 'Reading %s', path);
const data = await readFile(path, 'utf8');
if (data.includes('headscale')) {
return pid;
}
} catch (error) {
log.debug('INTG', 'Failed to read %s: %s', path, error);
log.debug('config', 'Failed to read %s: %s', path, error);
}
});
@ -176,10 +179,10 @@ export default class KubernetesIntegration extends Integration<T> {
}
}
log.debug('INTG', 'Found Headscale processes: %o', pids);
log.debug('config', 'Found Headscale processes: %o', pids);
if (pids.length > 1) {
log.error(
'INTG',
'config',
'Found %d Headscale processes: %s',
pids.length,
pids.join(', '),
@ -188,49 +191,45 @@ export default class KubernetesIntegration extends Integration<T> {
}
if (pids.length === 0) {
log.error('INTG', 'Could not find Headscale process');
log.error('config', 'Could not find Headscale process');
return false;
}
this.pid = pids[0];
log.info('INTG', 'Found Headscale process with PID: %d', this.pid);
log.info('config', 'Found Headscale process with PID: %d', this.pid);
return true;
} catch {
log.error('INTG', 'Failed to read /proc');
log.error('config', 'Failed to read /proc');
return false;
}
}
async onConfigChange() {
async onConfigChange(client: ApiClient) {
if (!this.pid) {
return;
}
try {
log.info('INTG', 'Sending SIGTERM to Headscale');
log.info('config', 'Sending SIGTERM to Headscale');
kill(this.pid, 'SIGTERM');
} catch (error) {
log.error('INTG', 'Failed to send SIGTERM to Headscale: %s', error);
log.debug('INTG', 'kill(1) error: %o', error);
log.error('config', 'Failed to send SIGTERM to Headscale: %s', error);
log.debug('config', 'kill(1) error: %o', error);
}
await setTimeout(1000);
let attempts = 0;
while (attempts <= this.maxAttempts) {
try {
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
await healthcheck();
log.info('INTG', 'Headscale is up and running');
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck();
if (status === false) {
throw new Error('Headscale is not running');
}
log.info('config', 'Headscale is up and running');
return;
} catch (error) {
if (error instanceof HeadscaleError && error.status === 401) {
break;
}
if (error instanceof HeadscaleError && error.status === 404) {
break;
}
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
@ -238,7 +237,7 @@ export default class KubernetesIntegration extends Integration<T> {
}
log.error(
'INTG',
'config',
'Missed restart deadline for Headscale (pid %d)',
this.pid,
);

View File

@ -3,9 +3,9 @@ import { platform } from 'node:os';
import { join, resolve } from 'node:path';
import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { HeadscaleError, healthcheck } from '~/utils/headscale';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
import { ApiClient } from '~/server/headscale/api-client';
import log from '~/utils/log';
import { HeadplaneConfig } from '../schema';
import { Integration } from './abstract';
type T = NonNullable<HeadplaneConfig['integration']>['proc'];
@ -19,11 +19,11 @@ export default class ProcIntegration extends Integration<T> {
async isAvailable() {
if (platform() !== 'linux') {
log.error('INTG', '/proc is only available on Linux');
log.error('config', '/proc is only available on Linux');
return false;
}
log.debug('INTG', 'Checking /proc for Headscale process');
log.debug('config', 'Checking /proc for Headscale process');
const dir = resolve('/proc');
try {
const subdirs = await readdir(dir);
@ -36,13 +36,13 @@ export default class ProcIntegration extends Integration<T> {
const path = join('/proc', dir, 'cmdline');
try {
log.debug('INTG', 'Reading %s', path);
log.debug('config', 'Reading %s', path);
const data = await readFile(path, 'utf8');
if (data.includes('headscale')) {
return pid;
}
} catch (error) {
log.error('INTG', 'Failed to read %s: %s', path, error);
log.error('config', 'Failed to read %s: %s', path, error);
}
});
@ -55,10 +55,10 @@ export default class ProcIntegration extends Integration<T> {
}
}
log.debug('INTG', 'Found Headscale processes: %o', pids);
log.debug('config', 'Found Headscale processes: %o', pids);
if (pids.length > 1) {
log.error(
'INTG',
'config',
'Found %d Headscale processes: %s',
pids.length,
pids.join(', '),
@ -67,49 +67,46 @@ export default class ProcIntegration extends Integration<T> {
}
if (pids.length === 0) {
log.error('INTG', 'Could not find Headscale process');
log.error('config', 'Could not find Headscale process');
return false;
}
this.pid = pids[0];
log.info('INTG', 'Found Headscale process with PID: %d', this.pid);
log.info('config', 'Found Headscale process with PID: %d', this.pid);
return true;
} catch {
log.error('INTG', 'Failed to read /proc');
log.error('config', 'Failed to read /proc');
return false;
}
}
async onConfigChange() {
async onConfigChange(client: ApiClient) {
if (!this.pid) {
return;
}
try {
log.info('INTG', 'Sending SIGTERM to Headscale');
log.info('config', 'Sending SIGTERM to Headscale');
kill(this.pid, 'SIGTERM');
} catch (error) {
log.error('INTG', 'Failed to send SIGTERM to Headscale: %s', error);
log.debug('INTG', 'kill(1) error: %o', error);
log.error('config', 'Failed to send SIGTERM to Headscale: %s', error);
log.debug('config', 'kill(1) error: %o', error);
}
await setTimeout(1000);
let attempts = 0;
while (attempts <= this.maxAttempts) {
try {
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
await healthcheck();
log.info('INTG', 'Headscale is up and running');
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck();
if (status === false) {
log.error('config', 'Headscale is not running');
return;
}
log.info('config', 'Headscale is up and running');
return;
} catch (error) {
if (error instanceof HeadscaleError && error.status === 401) {
break;
}
if (error instanceof HeadscaleError && error.status === 404) {
break;
}
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
@ -117,7 +114,7 @@ export default class ProcIntegration extends Integration<T> {
}
log.error(
'INTG',
'config',
'Missed restart deadline for Headscale (pid %d)',
this.pid,
);

225
app/server/config/loader.ts Normal file
View File

@ -0,0 +1,225 @@
import { constants, access, readFile } from 'node:fs/promises';
import { env, exit } from 'node:process';
import { type } from 'arktype';
import { configDotenv } from 'dotenv';
import { parseDocument } from 'yaml';
import log from '~/utils/log';
import { EnvOverrides, envVariables } from './env';
import {
HeadplaneConfig,
headplaneConfig,
partialHeadplaneConfig,
} from './schema';
// loadConfig is a has a lifetime of the entire application and is
// used to load the configuration for Headplane. It is called once.
//
// TODO: Potential for file watching on the configuration
// But this may not be necessary as a use-case anyways
export async function loadConfig({ loadEnv, path }: EnvOverrides) {
log.debug('config', 'Loading configuration file: %s', path);
const valid = await validateConfigPath(path);
if (!valid) {
exit(1);
}
const data = await loadConfigFile(path);
if (!data) {
exit(1);
}
let config = validateConfig({ ...data, debug: log.debugEnabled });
if (!config) {
exit(1);
}
if (!loadEnv) {
log.debug('config', 'Environment variable overrides are disabled');
log.debug('config', 'This also disables the loading of a .env file');
return config;
}
log.info('config', 'Loading a .env file (if available)');
configDotenv({ override: true });
config = coalesceEnv(config);
if (!config) {
exit(1);
}
return config;
}
export async function hp_loadConfig() {
// // OIDC Related Checks
// if (config.oidc) {
// if (!config.oidc.client_secret && !config.oidc.client_secret_path) {
// log.error('CFGX', 'OIDC configuration is missing a secret, disabling');
// log.error(
// 'CFGX',
// 'Please specify either `oidc.client_secret` or `oidc.client_secret_path`',
// );
// }
// if (config.oidc?.strict_validation) {
// const result = await testOidc(config.oidc);
// if (!result) {
// log.error('CFGX', 'OIDC configuration failed validation, disabling');
// }
// }
// }
}
async function validateConfigPath(path: string) {
try {
await access(path, constants.F_OK | constants.R_OK);
log.info('config', 'Found a valid configuration file at %s', path);
return true;
} catch (error) {
log.error('config', 'Unable to read a configuration file at %s', path);
log.error('config', '%s', error);
return false;
}
}
async function loadConfigFile(path: string): Promise<unknown> {
log.debug('config', 'Reading configuration file at %s', path);
try {
const data = await readFile(path, 'utf8');
const configYaml = parseDocument(data);
if (configYaml.errors.length > 0) {
log.error('config', 'Cannot parse configuration file at %s', path);
for (const error of configYaml.errors) {
log.error('config', ` - ${error.toString()}`);
}
return false;
}
if (configYaml.warnings.length > 0) {
log.warn(
'config',
'Warnings while parsing configuration file at %s',
path,
);
for (const warning of configYaml.warnings) {
log.warn('config', ` - ${warning.toString()}`);
}
}
return configYaml.toJSON() as unknown;
} catch (e) {
log.error('config', 'Error reading configuration file at %s', path);
log.error('config', '%s', e);
return false;
}
}
export function validateConfig(config: unknown) {
log.debug('config', 'Validating Headplane configuration');
const result = headplaneConfig(config);
if (result instanceof type.errors) {
log.error('config', 'Error validating Headplane configuration:');
for (const [number, error] of result.entries()) {
log.error('config', ` - (${number}): ${error.toString()}`);
}
return;
}
return result;
}
function coalesceEnv(config: HeadplaneConfig) {
const envConfig: Record<string, unknown> = {};
const rootKeys: string[] = Object.values(envVariables);
// Typescript is still insanely stupid at nullish filtering
const vars = Object.entries(env).filter(([key, value]) => {
if (!value) {
return false;
}
if (!key.startsWith('HEADPLANE_')) {
return false;
}
// Filter out the rootEnv configurations
if (rootKeys.includes(key)) {
return false;
}
return true;
}) as [string, string][];
log.debug('config', 'Coalescing %s environment variables', vars.length);
for (const [key, value] of vars) {
const configPath = key.replace('HEADPLANE_', '').toLowerCase().split('__');
log.debug(
'config',
` - ${key}=${new Array(value.length).fill('*').join('')}`,
);
let current = envConfig;
while (configPath.length > 1) {
const path = configPath.shift() as string;
if (!(path in current)) {
current[path] = {};
}
current = current[path] as Record<string, unknown>;
}
current[configPath[0]] = value;
}
const toMerge = coalesceConfig(envConfig);
if (!toMerge) {
return;
}
// Deep merge the environment variables into the configuration
// This will overwrite any existing values in the configuration
return deepMerge(config, toMerge);
}
export function coalesceConfig(config: unknown) {
log.debug('config', 'Revalidating config after coalescing variables');
const out = partialHeadplaneConfig(config);
if (out instanceof type.errors) {
log.error('config', 'Error parsing variables:');
for (const [number, error] of out.entries()) {
log.error('config', ` - (${number}): ${error.toString()}`);
}
return;
}
return out;
}
type DeepPartial<T> =
| {
[P in keyof T]?: DeepPartial<T[P]>;
}
| undefined;
function deepMerge<T>(target: T, source: DeepPartial<T>): T {
if (typeof target !== 'object' || typeof source !== 'object')
return source as T;
const result = { ...target } as T;
for (const key in source) {
const val = source[key];
if (val === undefined) {
continue;
}
if (typeof val === 'object') {
result[key] = deepMerge(result[key], val);
continue;
}
result[key] = val;
}
return result;
}

View File

@ -1,7 +1,5 @@
import { type } from 'arktype';
import log from '~server/utils/log';
export type HeadplaneConfig = typeof headplaneConfig.infer;
const stringToBool = type('string | boolean').pipe((v) => Boolean(v));
const serverConfig = type({
host: 'string.ip',
@ -9,7 +7,7 @@ const serverConfig = type({
cookie_secret: '32 <= string <= 32',
cookie_secure: stringToBool,
agent: type({
authkey: 'string',
authkey: 'string = ""',
ttl: 'number.integer = 180000', // Default to 3 minutes
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
})
@ -29,6 +27,7 @@ const oidcConfig = type({
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"',
redirect_uri: 'string.url?',
user_storage_file: 'string = "/var/lib/headplane/users.json"',
disable_api_key_login: stringToBool,
headscale_api_key: 'string',
strict_validation: stringToBool.default(true),
@ -42,10 +41,16 @@ const headscaleConfig = type({
config_strict: stringToBool,
}).onDeepUndeclaredKey('reject');
const containerLabel = type({
name: 'string',
value: 'string',
}).optional();
const dockerConfig = type({
enabled: stringToBool,
container_name: 'string',
socket: 'string = "unix:///var/run/docker.sock"',
container_label: containerLabel,
});
const kubernetesConfig = type({
@ -64,21 +69,21 @@ const integrationConfig = type({
'proc?': procConfig,
}).onDeepUndeclaredKey('reject');
const headplaneConfig = type({
debug: stringToBool,
server: serverConfig,
'oidc?': oidcConfig,
'integration?': integrationConfig,
headscale: headscaleConfig,
}).onDeepUndeclaredKey('reject');
const partialIntegrationConfig = type({
'docker?': dockerConfig.partial(),
'kubernetes?': kubernetesConfig.partial(),
'proc?': procConfig.partial(),
}).partial();
const partialHeadplaneConfig = type({
export const headplaneConfig = type({
debug: stringToBool,
server: serverConfig,
'oidc?': oidcConfig,
'integration?': integrationConfig,
headscale: headscaleConfig,
}).onDeepUndeclaredKey('delete');
export const partialHeadplaneConfig = type({
debug: stringToBool,
server: serverConfig.partial(),
'oidc?': oidcConfig.partial(),
@ -86,34 +91,5 @@ const partialHeadplaneConfig = type({
headscale: headscaleConfig.partial(),
}).partial();
export function validateConfig(config: unknown) {
log.debug('CFGX', 'Validating Headplane configuration...');
const out = headplaneConfig(config);
if (out instanceof type.errors) {
log.error('CFGX', 'Error parsing Headplane configuration:');
for (const [number, error] of out.entries()) {
log.error('CFGX', ` (${number}): ${error.toString()}`);
}
return;
}
log.debug('CFGX', 'Headplane configuration is valid.');
return out;
}
export function coalesceConfig(config: unknown) {
log.debug('CFGX', 'Validating coalescing vars for configuration...');
const out = partialHeadplaneConfig(config);
if (out instanceof type.errors) {
log.error('CFGX', 'Error parsing variables:');
for (const [number, error] of out.entries()) {
log.error('CFGX', ` (${number}): ${error.toString()}`);
}
return;
}
log.debug('CFGX', 'Coalescing variables is valid.');
return out;
}
export type HeadplaneConfig = typeof headplaneConfig.infer;
export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer;

View File

@ -0,0 +1,126 @@
import { readFile } from 'node:fs/promises';
import { Agent, Dispatcher, request } from 'undici';
import log from '~/utils/log';
import ResponseError from './api-error';
export async function createApiClient(base: string, certPath?: string) {
if (!certPath) {
return new ApiClient(new Agent(), base);
}
try {
log.debug('config', 'Loading certificate from %s', certPath);
const data = await readFile(certPath, 'utf8');
log.info('config', 'Using certificate from %s', certPath);
return new ApiClient(new Agent({ connect: { ca: data.trim() } }), base);
} catch (error) {
log.error('config', 'Failed to load Headscale TLS cert: %s', error);
log.debug('config', 'Error Details: %o', error);
return new ApiClient(new Agent(), base);
}
}
export class ApiClient {
private agent: Agent;
private base: string;
constructor(agent: Agent, base: string) {
this.agent = agent;
this.base = base;
}
private async defaultFetch(
url: string,
options?: Partial<Dispatcher.RequestOptions>,
) {
const method = options?.method ?? 'GET';
log.debug('api', '%s %s', method, url);
return await request(new URL(url, this.base), {
dispatcher: this.agent,
headers: {
...options?.headers,
Accept: 'application/json',
'User-Agent': `Headplane/${__VERSION__}`,
},
body: options?.body,
method,
});
}
async healthcheck() {
try {
const res = await this.defaultFetch('/health');
return res.statusCode === 200;
} catch (error) {
log.debug('api', 'Healthcheck failed %o', error);
return false;
}
}
async get<T = unknown>(url: string, key: string) {
const res = await this.defaultFetch(`/api/${url}`, {
headers: {
Authorization: `Bearer ${key}`,
},
});
if (res.statusCode >= 400) {
log.debug('api', 'GET %s failed with status %d', url, res.statusCode);
throw new ResponseError(res.statusCode, await res.body.text());
}
return res.body.json() as Promise<T>;
}
async post<T = unknown>(url: string, key: string, body?: unknown) {
const res = await this.defaultFetch(`/api/${url}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
});
if (res.statusCode >= 400) {
log.debug('api', 'POST %s failed with status %d', url, res.statusCode);
throw new ResponseError(res.statusCode, await res.body.text());
}
return res.body.json() as Promise<T>;
}
async put<T = unknown>(url: string, key: string, body?: unknown) {
const res = await this.defaultFetch(`/api/${url}`, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
});
if (res.statusCode >= 400) {
log.debug('api', 'PUT %s failed with status %d', url, res.statusCode);
throw new ResponseError(res.statusCode, await res.body.text());
}
return res.body.json() as Promise<T>;
}
async delete<T = unknown>(url: string, key: string) {
const res = await this.defaultFetch(`/api/${url}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${key}`,
},
});
if (res.statusCode >= 400) {
log.debug('api', 'DELETE %s failed with status %d', url, res.statusCode);
throw new ResponseError(res.statusCode, await res.body.text());
}
return res.body.json() as Promise<T>;
}
}

View File

@ -0,0 +1,19 @@
// Represents an error that occurred during a response
// Thrown when status codes are >= 400
export default class ResponseError extends Error {
status: number;
response: string;
responseObject?: Record<string, unknown>;
constructor(status: number, response: string) {
super(`Response Error (${status}): ${response}`);
this.name = 'ResponseError';
this.status = status;
this.response = response;
try {
// Try to parse the response as JSON to get a response object
this.responseObject = JSON.parse(response);
} catch {}
}
}

View File

@ -0,0 +1,283 @@
import { constants, access, readFile, writeFile } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import { type } from 'arktype';
import { Document, parseDocument } from 'yaml';
import log from '~/utils/log';
import { headscaleConfig } from './config-schema';
interface PatchConfig {
path: string;
value: unknown;
}
// We need a class for the config because we need to be able to
// support retrieving it via a getter but also be able to
// patch it and to query it for its mode
class HeadscaleConfig {
private config?: typeof headscaleConfig.infer;
private document?: Document;
private access: 'rw' | 'ro' | 'no';
private path?: string;
private writeLock = false;
constructor(
access: 'rw' | 'ro' | 'no',
config?: typeof headscaleConfig.infer,
document?: Document,
path?: string,
) {
this.access = access;
this.config = config;
this.document = document;
this.path = path;
}
readable() {
return this.access !== 'no';
}
writable() {
return this.access === 'rw';
}
get c() {
return this.config;
}
async patch(patches: PatchConfig[]) {
if (!this.path || !this.document || !this.readable() || !this.writable()) {
return;
}
log.debug('config', 'Patching Headscale configuration');
for (const patch of patches) {
const { path, value } = patch;
log.debug('config', 'Patching %s with %o', path, value);
// If the key is something like `test.bar."foo.bar"`, then we treat
// the foo.bar as a single key, and not as two keys, so that needs
// to be split correctly.
// Iterate through each character, and if we find a dot, we check if
// the next character is a quote, and if it is, we skip until the next
// quote, and then we skip the next character, which should be a dot.
// If it's not a quote, we split it.
const key = [];
let current = '';
let quote = false;
for (const char of path) {
if (char === '"') {
quote = !quote;
}
if (char === '.' && !quote) {
key.push(current);
current = '';
continue;
}
current += char;
}
key.push(current.replaceAll('"', ''));
if (value === null) {
this.document.deleteIn(key);
continue;
}
this.document.setIn(key, value);
}
// Revalidate our configuration and update the config
// object with the new configuration
log.info('config', 'Revalidating Headscale configuration');
const config = validateConfig(this.document.toJSON());
if (!config) {
return;
}
log.debug(
'config',
'Writing updated Headscale configuration to %s',
this.path,
);
// 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.path, this.document.toString(), 'utf8');
this.config = config;
this.writeLock = false;
return;
}
}
export async function loadHeadscaleConfig(path?: string, strict = true) {
if (!path) {
log.debug('config', 'No Headscale configuration file was provided');
return new HeadscaleConfig('no');
}
log.debug('config', 'Loading Headscale configuration file: %s', path);
const { r, w } = await validateConfigPath(path);
if (!r) {
return new HeadscaleConfig('no');
}
const document = await loadConfigFile(path);
if (!document) {
return new HeadscaleConfig('no');
}
if (!strict) {
return new HeadscaleConfig(
w ? 'rw' : 'ro',
augmentUnstrictConfig(document.toJSON()),
document,
path,
);
}
const config = validateConfig(document.toJSON());
if (!config) {
return new HeadscaleConfig('no');
}
return new HeadscaleConfig(w ? 'rw' : 'ro', config, document, path);
}
async function validateConfigPath(path: string) {
try {
await access(path, constants.F_OK | constants.R_OK);
log.info(
'config',
'Found a valid Headscale configuration file at %s',
path,
);
} catch (error) {
log.error(
'config',
'Unable to read a Headscale configuration file at %s',
path,
);
log.error('config', '%s', error);
return { w: false, r: false };
}
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
log.warn(
'config',
'Headscale configuration file at %s is not writable',
path,
);
return { w: false, r: true };
}
}
async function loadConfigFile(path: string) {
log.debug('config', 'Reading Headscale configuration file at %s', path);
try {
const data = await readFile(path, 'utf8');
const configYaml = parseDocument(data);
if (configYaml.errors.length > 0) {
log.error(
'config',
'Cannot parse Headscale configuration file at %s',
path,
);
for (const error of configYaml.errors) {
log.error('config', ` - ${error.toString()}`);
}
return false;
}
return configYaml;
} catch (e) {
log.error(
'config',
'Error reading Headscale configuration file at %s',
path,
);
log.error('config', '%s', e);
return false;
}
}
export function validateConfig(config: unknown) {
log.debug('config', 'Validating Headscale configuration');
const result = headscaleConfig(config);
if (result instanceof type.errors) {
log.error('config', 'Error validating Headscale configuration:');
for (const [number, error] of result.entries()) {
log.error('config', ` - (${number}): ${error.toString()}`);
}
return;
}
return result;
}
// If config_strict is false, we set the defaults and disable
// the schema checking for the values that are not present
function augmentUnstrictConfig(loaded: Partial<typeof headscaleConfig.infer>) {
log.debug('config', 'Augmenting Headscale configuration in non-strict mode');
const config = {
...loaded,
tls_letsencrypt_cache_dir:
loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache',
tls_letsencrypt_challenge_type:
loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01',
grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443',
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
randomize_client_port: loaded.randomize_client_port ?? false,
unix_socket: loaded.unix_socket ?? '/var/run/headscale/headscale.sock',
unix_socket_permission: loaded.unix_socket_permission ?? '0770',
log: loaded.log ?? {
level: 'info',
format: 'text',
},
logtail: loaded.logtail ?? {
enabled: false,
},
prefixes: loaded.prefixes ?? {
allocation: 'sequential',
v4: '',
v6: '',
},
dns: loaded.dns ?? {
nameservers: {
global: [],
split: {},
},
search_domains: [],
extra_records: [],
magic_dns: false,
base_domain: 'headscale.net',
},
};
log.warn('config', 'Headscale configuration was loaded in non-strict mode');
log.warn('config', 'This is very dangerous and comes with a few caveats:');
log.warn('config', ' - Headplane could very easily crash');
log.warn('config', ' - Headplane could break your Headscale installation');
log.warn(
'config',
' - The UI could throw random errors/show incorrect data',
);
return config as typeof headscaleConfig.infer;
}

View File

@ -1,5 +1,4 @@
import { type } from 'arktype';
import log from '~server/utils/log';
const goBool = type('boolean | "true" | "false"').pipe((v) => {
if (v === 'true') return true;
@ -46,7 +45,7 @@ const databaseConfig = type({
// Not as strict parsing because we just need the values
// to be slightly truthy enough to safely modify them
export type HeadscaleConfig = typeof headscaleConfig.infer;
const headscaleConfig = type({
export const headscaleConfig = type({
server_url: 'string',
listen_addr: 'string',
'metrics_listen_addr?': 'string',
@ -56,8 +55,8 @@ const headscaleConfig = type({
private_key_path: 'string',
},
prefixes: {
v4: 'string',
v6: 'string',
v4: 'string?',
v6: 'string?',
allocation: '"sequential" | "random" = "sequential"',
},
derp: {
@ -147,82 +146,3 @@ const headscaleConfig = type({
randomize_client_port: goBool.default(false),
});
export function validateConfig(config: unknown, strict: boolean) {
log.debug('CFGX', 'Validating Headscale configuration...');
const out = strict
? headscaleConfig(config)
: headscaleConfig(augmentUnstrictConfig(config as HeadscaleConfig));
if (out instanceof type.errors) {
log.error('CFGX', 'Error parsing Headscale configuration:');
for (const [number, error] of out.entries()) {
log.error('CFGX', ` (${number}): ${error.toString()}`);
}
log.error('CFGX', '');
log.error('CFGX', 'Resolve these issues and try again.');
log.error('CFGX', 'Headplane will operate without the config');
log.error('CFGX', '');
return;
}
log.debug('CFGX', 'Headscale configuration is valid.');
return out;
}
// If config_strict is false, we set the defaults and disable
// the schema checking for the values that are not present
function augmentUnstrictConfig(
loaded: Partial<HeadscaleConfig>,
): HeadscaleConfig {
log.debug('CFGX', 'Loaded Headscale configuration in non-strict mode');
const config = {
...loaded,
tls_letsencrypt_cache_dir:
loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache',
tls_letsencrypt_challenge_type:
loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01',
grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443',
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
randomize_client_port: loaded.randomize_client_port ?? false,
unix_socket: loaded.unix_socket ?? '/var/run/headscale/headscale.sock',
unix_socket_permission: loaded.unix_socket_permission ?? '0770',
log: loaded.log ?? {
level: 'info',
format: 'text',
},
logtail: loaded.logtail ?? {
enabled: false,
},
prefixes: loaded.prefixes ?? {
allocation: 'sequential',
v4: '',
v6: '',
},
dns: loaded.dns ?? {
nameservers: {
global: [],
split: {},
},
search_domains: [],
extra_records: [],
magic_dns: false,
base_domain: 'headscale.net',
},
};
log.warn('CFGX', 'Loaded Headscale configuration in non-strict mode');
log.warn('CFGX', 'By using this mode you forfeit GitHub issue support');
log.warn('CFGX', 'This is very dangerous and comes with a few caveats:');
log.warn('CFGX', ' Headplane could very easily crash');
log.warn('CFGX', ' Headplane could break your Headscale installation');
log.warn('CFGX', ' The UI could throw random errors/show incorrect data');
log.warn('CFGX', '');
return config as HeadscaleConfig;
}

105
app/server/index.ts Normal file
View File

@ -0,0 +1,105 @@
import { env, versions } from 'node:process';
import type { UpgradeWebSocket } from 'hono/ws';
import { createHonoServer } from 'react-router-hono-server/node';
import type { WebSocket } from 'ws';
import log from '~/utils/log';
import { configureConfig, configureLogger, envVariables } from './config/env';
import { loadIntegration } from './config/integration';
import { loadConfig } from './config/loader';
import { createApiClient } from './headscale/api-client';
import { loadHeadscaleConfig } from './headscale/config-loader';
import { loadAgentSocket } from './web/agent';
import { createOidcClient } from './web/oidc';
import { createSessionStorage } from './web/sessions';
declare global {
const __PREFIX__: string;
const __VERSION__: string;
}
// MARK: Side-Effects
// This module contains a side-effect because everything running here
// exists for the lifetime of the process, making it appropriate.
log.info('server', 'Running Node.js %s', versions.node);
configureLogger(env[envVariables.debugLog]);
const config = await loadConfig(
configureConfig({
loadEnv: env[envVariables.envOverrides],
path: env[envVariables.configPath],
}),
);
// 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
// helper that can issue and revoke cookies.
export type LoadContext = typeof appLoadContext;
const appLoadContext = {
config,
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
),
// TODO: Better cookie options in config
sessions: await createSessionStorage(
{
name: '_hp_session',
maxAge: 60 * 60 * 24, // 24 hours
secure: config.server.cookie_secure,
secrets: [config.server.cookie_secret],
},
config.oidc?.user_storage_file,
),
client: await createApiClient(
config.headscale.url,
config.headscale.tls_cert_path,
),
agents: await loadAgentSocket(
config.server.agent.authkey,
config.server.agent.cache_path,
config.server.agent.ttl,
),
integration: await loadIntegration(config.integration),
oidc: config.oidc ? await createOidcClient(config.oidc) : undefined,
};
declare module 'react-router' {
interface AppLoadContext extends LoadContext {}
}
export default createHonoServer({
useWebSocket: true,
overrideGlobalObjects: true,
port: config.server.port,
hostname: config.server.host,
// Only log in development mode
defaultLogger: import.meta.env.DEV,
getLoadContext() {
// TODO: This is the place where we can handle reverse proxy translation
// This is better than doing it in the OIDC client, since we can do it
// for all requests, not just OIDC ones.
return appLoadContext;
},
configure(app, { upgradeWebSocket }) {
const agentManager = appLoadContext.agents;
if (agentManager) {
app.get(
`${__PREFIX__}/_dial`,
// We need this since we cannot pass the WSEvents context
// Also important to not pass the callback directly
// since we need to retain `this` context
(upgradeWebSocket as UpgradeWebSocket<WebSocket>)((c) =>
agentManager.configureSocket(c),
),
);
}
},
listeningListener(info) {
log.info('server', 'Running on %s:%s', info.address, info.port);
},
});

283
app/server/web/agent.ts Normal file
View File

@ -0,0 +1,283 @@
import { createHash } from 'node:crypto';
import { open, readFile, writeFile } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import { getConnInfo } from '@hono/node-server/conninfo';
import { type } from 'arktype';
import type { Context } from 'hono';
import type { WSContext, WSEvents } from 'hono/ws';
import { WebSocket } from 'ws';
import { HostInfo } from '~/types';
import log from '~/utils/log';
export async function loadAgentSocket(
authkey: string,
path: string,
ttl: number,
) {
if (authkey.length === 0) {
return;
}
try {
const handle = await open(path, 'w');
log.info('agent', 'Using agent cache file at %s', path);
await handle.close();
} catch (error) {
log.info('agent', 'Agent cache file not accessible at %s', path);
log.debug('agent', 'Error details: %s', error);
return;
}
const cache = new TimedCache<HostInfo>(ttl, path);
return new AgentManager(cache, authkey);
}
class AgentManager {
private cache: TimedCache<HostInfo>;
private agents: Map<string, WSContext>;
private timers: Map<string, NodeJS.Timeout>;
private authkey: string;
constructor(cache: TimedCache<HostInfo>, authkey: string) {
this.cache = cache;
this.authkey = authkey;
this.agents = new Map();
this.timers = new Map();
}
tailnetIDs() {
return Array.from(this.agents.keys());
}
lookup(nodeIds: string[]) {
const entries = this.cache.toJSON();
const missing = nodeIds.filter((nodeId) => !entries[nodeId]);
if (missing.length > 0) {
this.requestData(missing);
}
return entries;
}
// Request data from all connected agents
// This does not return anything, but caches the data which then needs to be
// queried by the caller separately.
private requestData(nodeList: string[]) {
const NodeIDs = [...new Set(nodeList)];
NodeIDs.map((node) => {
log.debug('agent', 'Requesting agent data for %s', node);
});
for (const agent of this.agents.values()) {
agent.send(JSON.stringify({ NodeIDs }));
}
}
// Since we are using Node, Hono is built on 'ws' WebSocket types.
configureSocket(c: Context): WSEvents<WebSocket> {
return {
onOpen: (_, ws) => {
const id = c.req.header('x-headplane-tailnet-id');
if (!id) {
log.warn(
'agent',
'Rejecting an agent WebSocket connection without a tailnet ID',
);
ws.close(1008, 'ERR_INVALID_TAILNET_ID');
return;
}
const auth = c.req.header('authorization');
if (auth !== `Bearer ${this.authkey}`) {
log.warn('agent', 'Rejecting an unauthorized WebSocket connection');
const info = getConnInfo(c);
if (info.remote.address) {
log.warn('agent', 'Agent source IP: %s', info.remote.address);
}
ws.close(1008, 'ERR_UNAUTHORIZED');
return;
}
const pinger = setInterval(() => {
if (ws.readyState !== 1) {
clearInterval(pinger);
return;
}
ws.raw?.ping();
}, 30000);
this.agents.set(id, ws);
this.timers.set(id, pinger);
},
onClose: () => {
const id = c.req.header('x-headplane-tailnet-id');
if (!id) {
return;
}
clearInterval(this.timers.get(id));
this.agents.delete(id);
},
onError: (event, ws) => {
const id = c.req.header('x-headplane-tailnet-id');
if (!id) {
return;
}
clearInterval(this.timers.get(id));
if (event instanceof ErrorEvent) {
log.error('agent', 'WebSocket error: %s', event.message);
}
log.debug('agent', 'Closing agent WebSocket connection');
ws.close(1011, 'ERR_INTERNAL_ERROR');
},
// This is where we receive the data from the agent
// Requests are made in the AgentManager.requestData function
onMessage: (event, ws) => {
const id = c.req.header('x-headplane-tailnet-id');
if (!id) {
return;
}
const data = JSON.parse(event.data.toString());
log.debug('agent', 'Received agent data from %s', id);
for (const [node, info] of Object.entries<HostInfo>(data)) {
this.cache.set(node, info);
log.debug('agent', 'Cached HostInfo for %s', node);
}
},
};
}
}
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;
}
}

150
app/server/web/oidc.ts Normal file
View File

@ -0,0 +1,150 @@
import { readFile } from 'node:fs/promises';
import * as client from 'openid-client';
import log from '~/utils/log';
import type { HeadplaneConfig } from '../config/schema';
async function loadClientSecret(path: string) {
// 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) {
for (const match of matches) {
const env = match.slice(2, -1);
const value = process.env[env];
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;
} 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':
return client.ClientSecretBasic;
case 'client_secret_jwt':
return client.ClientSecretJwt;
default:
throw new Error('Invalid client authentication method');
}
}
// Loads and configures an OIDC client to support OIDC authentication.
// 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);
const oidc = await client.discovery(
new URL(config.issuer),
config.client_id,
secret,
clientAuthMethod(config.token_endpoint_auth_method)(secret),
);
const metadata = oidc.serverMetadata();
if (!metadata.authorization_endpoint) {
log.error(
'config',
'Issuer discovery did not return `authorization_endpoint`',
);
log.error('config', 'OIDC server does not support authorization code flow');
return;
}
if (!metadata.token_endpoint) {
log.error('config', 'Issuer discovery did not return `token_endpoint`');
log.error('config', 'OIDC server does not support token exchange');
return;
}
// If this field is missing, assume the server supports all response types
// and that we can continue safely.
if (metadata.response_types_supported) {
if (!metadata.response_types_supported.includes('code')) {
log.error(
'config',
'Issuer discovery `response_types_supported` does not include `code`',
);
log.error('config', 'OIDC server does not support code flow');
return;
}
}
if (metadata.token_endpoint_auth_methods_supported) {
if (
!metadata.token_endpoint_auth_methods_supported.includes(
config.token_endpoint_auth_method,
)
) {
log.error(
'config',
'Issuer discovery `token_endpoint_auth_methods_supported` does not include `%s`',
config.token_endpoint_auth_method,
);
log.error(
'config',
'OIDC server does not support %s',
config.token_endpoint_auth_method,
);
return;
}
}
if (!metadata.userinfo_endpoint) {
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(
'config',
'Authorization endpoint: %s',
metadata.authorization_endpoint,
);
log.debug('config', 'Token endpoint: %s', metadata.token_endpoint);
log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint);
return oidc;
}

144
app/server/web/roles.ts Normal file
View File

@ -0,0 +1,144 @@
export type Capabilities = (typeof Capabilities)[keyof typeof Capabilities];
export const Capabilities = {
// Can access the admin console
ui_access: 1 << 0,
// Read tailnet policy file (unimplemented)
read_policy: 1 << 1,
// Write tailnet policy file (unimplemented)
write_policy: 1 << 2,
// Read network configurations
read_network: 1 << 3,
// Write network configurations, for example, enable MagicDNS, split DNS,
// make subnet, or allow a node to be an exit node, enable HTTPS
write_network: 1 << 4,
// Read feature configuration (unimplemented)
read_feature: 1 << 5,
// Write feature configuration, for example, enable Taildrop (unimplemented)
write_feature: 1 << 6,
// Configure user & group provisioning (unimplemented)
configure_iam: 1 << 7,
// Read machines, for example, see machine names and status
read_machines: 1 << 8,
// Write machines, for example, approve, rename, and remove machines
write_machines: 1 << 9,
// Read users and user roles
read_users: 1 << 10,
// Write users and user roles, for example, remove users,
// approve users, make Admin
write_users: 1 << 11,
// Can generate authkeys (unimplemented)
generate_authkeys: 1 << 12,
// Can use any tag (without being tag owner) (unimplemented)
use_tags: 1 << 13,
// Write tailnet name (unimplemented)
write_tailnet: 1 << 14,
// Owner flag
owner: 1 << 15,
} as const;
export type Roles = [keyof typeof Roles];
export const Roles = {
owner:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet |
Capabilities.owner,
admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet,
network_admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.write_policy |
Capabilities.read_network |
Capabilities.write_network |
Capabilities.read_feature |
Capabilities.read_machines |
Capabilities.read_users |
Capabilities.generate_authkeys |
Capabilities.use_tags |
Capabilities.write_tailnet,
it_admin:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.read_network |
Capabilities.read_feature |
Capabilities.write_feature |
Capabilities.configure_iam |
Capabilities.read_machines |
Capabilities.write_machines |
Capabilities.read_users |
Capabilities.write_users |
Capabilities.generate_authkeys,
auditor:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.read_network |
Capabilities.read_feature |
Capabilities.read_machines |
Capabilities.read_users,
// Default role for new users with 0 capabilities on the UI side of things
member: 0,
} as const;
export type Role = keyof typeof Roles;
export type Capability = keyof typeof Capabilities;
export function hasCapability(role: Role, capability: Capability): boolean {
return (Roles[role] & Capabilities[capability]) !== 0;
}
export function getRoleFromCapabilities(capabilities: Capabilities): Role {
const iterable = Roles as Record<string, Capabilities>;
for (const role in iterable) {
if (iterable[role] === capabilities) {
return role as Role;
}
}
return 'member';
}

304
app/server/web/sessions.ts Normal file
View File

@ -0,0 +1,304 @@
import { open, readFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { exit } from 'node:process';
import {
CookieSerializeOptions,
Session,
SessionStorage,
createCookieSessionStorage,
} from 'react-router';
import log from '~/utils/log';
import { Capabilities, Roles } from './roles';
export interface AuthSession {
state: 'auth';
api_key: string;
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
}
export interface OidcFlowSession {
state: 'flow';
oidc: {
state: string;
nonce: string;
code_verifier: string;
redirect_uri: string;
};
}
type JoinedSession = AuthSession | OidcFlowSession;
interface Error {
error: string;
}
interface CookieOptions {
name: string;
secure: boolean;
maxAge: number;
secrets: string[];
domain?: string;
}
class Sessionizer {
private storage: SessionStorage<JoinedSession, Error>;
private caps: Record<string, { c: Capabilities; oo?: boolean }>;
private capsPath?: string;
constructor(
options: CookieOptions,
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
// on something that wraps the route calling auth. The top-level routes
// that call this are wrapped with try/catch to handle the error.
async auth(request: Request) {
const cookie = request.headers.get('cookie');
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 {
const role = this.caps[subject]?.c;
if (!role) {
return;
}
// We need this in string form based on Object.keys of the roles
for (const [key, value] of Object.entries(Roles)) {
if (value === role) {
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
// capabilities. If not, return false. Can throw since it calls auth()
async check(request: Request, capabilities: Capabilities) {
const session = await this.auth(request);
const { subject } = session.get('user') ?? {};
if (!subject) {
return false;
}
// 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;
}
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
async reassignSubject(subject: string, role: keyof typeof Roles) {
// Check if we are owner
if (this.roleForSubject(subject) === 'owner') {
return false;
}
this.caps[subject] = {
...this.caps[subject], // Preserve the existing capabilities if any
c: Roles[role],
};
await this.flushUserDatabase();
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(
options: CookieOptions,
usersPath?: string,
) {
const map: Record<
string,
{
c: number;
oo?: boolean;
}
> = {};
if (usersPath) {
// 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) {
map[user.u] = {
c: user.c,
oo: user.oo,
};
}
}
return new Sessionizer(options, map, usersPath);
}
async function loadUserFile(path: string) {
const realPath = resolve(path);
try {
const handle = await open(realPath, 'a+');
log.info('config', 'Using user database file at %s', realPath);
await handle.close();
} catch (error) {
log.info('config', 'User database file not accessible at %s', realPath);
log.debug('config', 'Error details: %s', error);
exit(1);
}
try {
const data = await readFile(realPath, 'utf8');
const users = JSON.parse(data.trim()) as {
u?: string;
c?: number;
oo?: boolean;
}[];
// Never trust user input
return users.filter(
(user) => user.u !== undefined && user.c !== undefined,
) as {
u: string;
c: number;
oo?: boolean;
}[];
} catch (error) {
log.debug('config', 'Error reading user database file: %s', error);
log.debug('config', 'Using empty user database');
return [];
}
}

View File

@ -1,204 +0,0 @@
import { constants, access, readFile, writeFile } from 'node:fs/promises';
import { Document, parseDocument } from 'yaml';
import { hp_getIntegration } from '~/utils/integration/loader';
import mutex from '~/utils/mutex';
import { hp_getConfig } from '~server/context/global';
import log from '~server/utils/log';
import { HeadscaleConfig, validateConfig } from './parser';
let runtimeYaml: Document | undefined = undefined;
let runtimeConfig: HeadscaleConfig | undefined = undefined;
let runtimePath: string | undefined = undefined;
let runtimeMode: 'rw' | 'ro' | 'no' = 'no';
const runtimeLock = mutex();
export type ConfigModes =
| {
mode: 'rw' | 'ro';
config: HeadscaleConfig;
}
| {
mode: 'no';
config: undefined;
};
export function hs_getConfig(): ConfigModes {
if (runtimeMode === 'no') {
return {
mode: 'no',
config: undefined,
};
}
runtimeLock.acquire();
// We can assert if mode is not 'no'
const config = runtimeConfig!;
runtimeLock.release();
return {
mode: runtimeMode,
config: config,
};
}
export async function hs_loadConfig(path?: string, strict?: boolean) {
if (runtimeConfig !== undefined) {
return;
}
runtimeLock.acquire();
if (!path) {
runtimeLock.release();
return;
}
runtimeMode = await validateConfigPath(path);
if (runtimeMode === 'no') {
runtimeLock.release();
return;
}
runtimePath = path;
const rawConfig = await loadConfigFile(path);
if (!rawConfig) {
return;
}
const config = validateConfig(rawConfig, strict ?? true);
if (!config) {
runtimeMode = 'no';
}
runtimeConfig = config;
}
async function validateConfigPath(path: string) {
log.debug('CFGX', `Validating Headscale configuration file at ${path}`);
try {
await access(path, constants.F_OK | constants.R_OK);
log.info('CFGX', `Headscale configuration found at ${path}`);
} catch (e) {
log.error('CFGX', `Headscale configuration not readable at ${path}`);
log.error('CFGX', `${e}`);
return 'no';
}
let writeable = false;
try {
await access(path, constants.W_OK);
writeable = true;
} catch (e) {
log.warn('CFGX', `Headscale configuration not writeable at ${path}`);
log.debug('CFGX', `${e}`);
}
return writeable ? 'rw' : 'ro';
}
async function loadConfigFile(path: string) {
log.debug('CFGX', `Loading Headscale configuration file at ${path}`);
try {
const data = await readFile(path, 'utf8');
const configYaml = parseDocument(data);
if (configYaml.errors.length > 0) {
log.error(
'CFGX',
`Error parsing Headscale configuration file at ${path}`,
);
for (const error of configYaml.errors) {
log.error('CFGX', ` ${error.toString()}`);
}
return;
}
runtimeYaml = configYaml;
return configYaml.toJSON() as unknown;
} catch (e) {
log.error('CFGX', `Error reading Headscale configuration file at ${path}`);
log.error('CFGX', `${e}`);
return;
}
}
type PatchConfig = { path: string; value: unknown };
export async function hs_patchConfig(patches: PatchConfig[]) {
if (!runtimeConfig || !runtimeYaml || !runtimePath) {
log.error('CFGX', 'Headscale configuration not loaded');
return;
}
if (runtimeMode === 'no') {
return;
}
if (runtimeMode === 'ro') {
throw new Error('Headscale configuration is read-only');
}
runtimeLock.acquire();
const config = runtimeConfig!;
log.debug('CFGX', 'Patching Headscale configuration');
for (const patch of patches) {
const { path, value } = patch;
log.debug('CFGX', 'Patching %s in Headscale configuration', path);
// If the key is something like `test.bar."foo.bar"`, then we treat
// the foo.bar as a single key, and not as two keys, so that needs
// to be split correctly.
// Iterate through each character, and if we find a dot, we check if
// the next character is a quote, and if it is, we skip until the next
// quote, and then we skip the next character, which should be a dot.
// If it's not a quote, we split it.
const key = [];
let current = '';
let quote = false;
for (const char of path) {
if (char === '"') {
quote = !quote;
}
if (char === '.' && !quote) {
key.push(current);
current = '';
continue;
}
current += char;
}
key.push(current.replaceAll('"', ''));
// Deletion handling
if (value === null) {
runtimeYaml.deleteIn(key);
continue;
}
runtimeYaml.setIn(key, value);
}
// Revalidate the configuration
const context = hp_getConfig();
const newRawConfig = runtimeYaml.toJSON() as unknown;
runtimeConfig = context.headscale.config_strict
? validateConfig(newRawConfig, true)
: (newRawConfig as HeadscaleConfig);
log.debug(
'CFGX',
'Writing patched Headscale configuration to %s',
runtimePath,
);
await writeFile(runtimePath, runtimeYaml.toString(), 'utf8');
runtimeLock.release();
}
// IMPORTANT THIS IS A SIDE EFFECT ON INIT
// TODO: Replace this into the new singleton system
const context = hp_getConfig();
hs_loadConfig(context.headscale.config_path, context.headscale.config_strict);
hp_getIntegration();

View File

@ -1,149 +0,0 @@
import { request } from 'undici';
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
import log from '~server/utils/log';
export class HeadscaleError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'HeadscaleError';
this.status = status;
}
}
export class FatalError extends Error {
constructor() {
super(
'The Headscale server is not accessible or the supplied API key is invalid',
);
this.name = 'FatalError';
}
}
export async function healthcheck() {
log.debug('APIC', 'GET /health');
const health = new URL('health', hp_getConfig().headscale.url);
const response = await request(health.toString(), {
dispatcher: hp_getSingleton('api_agent'),
headers: {
Accept: 'application/json',
},
});
// Intentionally not catching
return response.statusCode === 200;
}
export async function pull<T>(url: string, key: string) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?');
}
const prefix = hp_getConfig().headscale.url;
log.debug('APIC', 'GET %s', `${prefix}/api/${url}`);
const response = await request(`${prefix}/api/${url}`, {
dispatcher: hp_getSingleton('api_agent'),
headers: {
Authorization: `Bearer ${key}`,
},
});
if (response.statusCode >= 400) {
log.debug(
'APIC',
'GET %s failed with status %d',
`${prefix}/api/${url}`,
response.statusCode,
);
throw new HeadscaleError(await response.body.text(), response.statusCode);
}
return response.body.json() as Promise<T>;
}
export async function post<T>(url: string, key: string, body?: unknown) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?');
}
const prefix = hp_getConfig().headscale.url;
log.debug('APIC', 'POST %s', `${prefix}/api/${url}`);
const response = await request(`${prefix}/api/${url}`, {
dispatcher: hp_getSingleton('api_agent'),
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
});
if (response.statusCode >= 400) {
log.debug(
'APIC',
'POST %s failed with status %d',
`${prefix}/api/${url}`,
response.statusCode,
);
throw new HeadscaleError(await response.body.text(), response.statusCode);
}
return response.body.json() as Promise<T>;
}
export async function put<T>(url: string, key: string, body?: unknown) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?');
}
const prefix = hp_getConfig().headscale.url;
log.debug('APIC', 'PUT %s', `${prefix}/api/${url}`);
const response = await request(`${prefix}/api/${url}`, {
dispatcher: hp_getSingleton('api_agent'),
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
});
if (response.statusCode >= 400) {
log.debug(
'APIC',
'PUT %s failed with status %d',
`${prefix}/api/${url}`,
response.statusCode,
);
throw new HeadscaleError(await response.body.text(), response.statusCode);
}
return response.body.json() as Promise<T>;
}
export async function del<T>(url: string, key: string) {
if (!key || key === 'undefined' || key.length === 0) {
throw new Error('Missing API key, could this be a cookie setting issue?');
}
const prefix = hp_getConfig().headscale.url;
log.debug('APIC', 'DELETE %s', `${prefix}/api/${url}`);
const response = await request(`${prefix}/api/${url}`, {
dispatcher: hp_getSingleton('api_agent'),
method: 'DELETE',
headers: {
Authorization: `Bearer ${key}`,
},
});
if (response.statusCode >= 400) {
log.debug(
'APIC',
'DELETE %s failed with status %d',
`${prefix}/api/${url}`,
response.statusCode,
);
throw new HeadscaleError(await response.body.text(), response.statusCode);
}
return response.body.json() as Promise<T>;
}

View File

@ -1,142 +0,0 @@
import { constants, access } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import { Client } from 'undici';
import { HeadscaleError, healthcheck, pull } from '~/utils/headscale';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
import { Integration } from './abstract';
type T = NonNullable<HeadplaneConfig['integration']>['docker'];
export default class DockerIntegration extends Integration<T> {
private maxAttempts = 10;
private client: Client | undefined;
get name() {
return 'Docker';
}
async isAvailable() {
if (this.context.container_name.length === 0) {
log.error('INTG', 'Docker container name is empty');
return false;
}
log.info('INTG', 'Using container: %s', this.context.container_name);
let url: URL | undefined;
try {
url = new URL(this.context.socket);
} catch {
log.error('INTG', 'Invalid Docker socket path: %s', this.context.socket);
return false;
}
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
log.error('INTG', 'Invalid Docker socket protocol: %s', url.protocol);
return false;
}
// The API is available as an HTTP endpoint and this
// will simplify the fetching logic in undici
if (url.protocol === 'tcp:') {
// Apparently setting url.protocol doesn't work anymore?
const fetchU = url.href.replace(url.protocol, 'http:');
try {
log.info('INTG', 'Checking API: %s', fetchU);
await fetch(new URL('/v1.30/version', fetchU).href);
} catch (error) {
log.error('INTG', 'Failed to connect to Docker API: %s', error);
log.debug('INTG', 'Connection error: %o', error);
return false;
}
this.client = new Client(fetchU);
}
// Check if the socket is accessible
if (url.protocol === 'unix:') {
try {
log.info('INTG', 'Checking socket: %s', url.pathname);
await access(url.pathname, constants.R_OK);
} catch (error) {
log.error('INTG', 'Failed to access Docker socket: %s', url.pathname);
log.debug('INTG', 'Access error: %o', error);
return false;
}
this.client = new Client('http://localhost', {
socketPath: url.pathname,
});
}
return this.client !== undefined;
}
async onConfigChange() {
if (!this.client) {
return;
}
log.info('INTG', 'Restarting Headscale via Docker');
let attempts = 0;
while (attempts <= this.maxAttempts) {
log.debug(
'INTG',
'Restarting container: %s (attempt %d)',
this.context.container_name,
attempts,
);
const response = await this.client.request({
method: 'POST',
path: `/v1.30/containers/${this.context.container_name}/restart`,
});
if (response.statusCode !== 204) {
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
continue;
}
const stringCode = response.statusCode.toString();
const body = await response.body.text();
throw new Error(`API request failed: ${stringCode} ${body}`);
}
break;
}
attempts = 0;
while (attempts <= this.maxAttempts) {
try {
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
await healthcheck();
log.info('INTG', 'Headscale is up and running');
return;
} catch (error) {
if (error instanceof HeadscaleError && error.status === 401) {
break;
}
if (error instanceof HeadscaleError && error.status === 404) {
break;
}
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
continue;
}
log.error(
'INTG',
'Missed restart deadline for %s',
this.context.container_name,
);
return;
}
}
}
}

View File

@ -1,72 +0,0 @@
import { hp_getConfig } from '~server/context/global';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
import { Integration } from './abstract';
import dockerIntegration from './docker';
import kubernetesIntegration from './kubernetes';
import procIntegration from './proc';
let runtimeIntegration: Integration<unknown> | undefined = undefined;
export function hp_getIntegration() {
return runtimeIntegration;
}
export async function hp_loadIntegration(
context: HeadplaneConfig['integration'],
) {
const integration = getIntegration(context);
if (!integration) {
return;
}
try {
const res = await integration.isAvailable();
if (!res) {
log.error('INTG', 'Integration %s is not available', integration);
return;
}
} catch (error) {
log.error('INTG', 'Failed to load integration %s: %s', integration, error);
log.debug('INTG', 'Loading error: %o', error);
return;
}
runtimeIntegration = integration;
}
function getIntegration(integration: HeadplaneConfig['integration']) {
const docker = integration?.docker;
const k8s = integration?.kubernetes;
const proc = integration?.proc;
if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) {
log.debug('INTG', 'No integrations enabled');
return;
}
if (docker?.enabled && k8s?.enabled && proc?.enabled) {
log.error('INTG', 'Multiple integrations enabled, please pick one only');
return;
}
if (docker?.enabled) {
log.info('INTG', 'Using Docker integration');
return new dockerIntegration(integration?.docker);
}
if (k8s?.enabled) {
log.info('INTG', 'Using Kubernetes integration');
return new kubernetesIntegration(integration?.kubernetes);
}
if (proc?.enabled) {
log.info('INTG', 'Using Proc integration');
return new procIntegration(integration?.proc);
}
}
// IMPORTANT THIS IS A SIDE EFFECT ON INIT
// TODO: Switch this to the new singleton system
const context = hp_getConfig();
hp_loadIntegration(context.integration);

67
app/utils/live-data.tsx Normal file
View File

@ -0,0 +1,67 @@
import { createContext, useContext, useEffect, useState } from 'react';
import { useRevalidator } from 'react-router';
import { useInterval } from 'usehooks-ts';
const LiveDataPausedContext = createContext({
paused: false,
setPaused: (_: boolean) => {},
});
interface LiveDataProps {
children: React.ReactNode;
}
export function LiveDataProvider({ children }: LiveDataProps) {
const [paused, setPaused] = useState(false);
const revalidator = useRevalidator();
// Document is marked as optional here because it's not available in SSR
// The optional chain means if document is not defined, visible is false
const [visible, setVisible] = useState(
() =>
typeof document !== 'undefined' && document.visibilityState === 'visible',
);
// Function to revalidate safely
const revalidateIfIdle = () => {
if (revalidator.state === 'idle') {
revalidator.revalidate();
}
};
useEffect(() => {
const handleVisibilityChange = () => {
setVisible(document.visibilityState === 'visible');
if (!paused && document.visibilityState === 'visible') {
revalidateIfIdle();
}
};
window.addEventListener('online', revalidateIfIdle);
document.addEventListener('focus', revalidateIfIdle);
document.addEventListener('visibilitychange', handleVisibilityChange);
return () => {
window.removeEventListener('online', revalidateIfIdle);
document.removeEventListener('focus', revalidateIfIdle);
document.removeEventListener('visibilitychange', handleVisibilityChange);
};
}, [paused, revalidator]);
// Poll only when visible and not paused
useInterval(revalidateIfIdle, visible && !paused ? 3000 : null);
return (
<LiveDataPausedContext.Provider value={{ paused, setPaused }}>
{children}
</LiveDataPausedContext.Provider>
);
}
export function useLiveData() {
const context = useContext(LiveDataPausedContext);
return {
pause: () => context.setPaused(true),
resume: () => context.setPaused(false),
};
}

31
app/utils/log.ts Normal file
View File

@ -0,0 +1,31 @@
// MARK: Side-Effects
// This module contains a side-effect because everything running here
// is static and logger is later modified in `app/server/index.ts` to
// disable debug logging if the `HEADPLANE_DEBUG_LOG` specifies as such.
const levels = ['info', 'warn', 'error', 'debug'] as const;
type Category = 'server' | 'config' | 'agent' | 'api' | 'auth';
export interface Logger
extends Record<
(typeof levels)[number],
(category: Category, message: string, ...args: unknown[]) => void
> {
debugEnabled: boolean;
}
export default {
debugEnabled: true,
...Object.fromEntries(
levels.map((level) => [
level,
(category: Category, message: string, ...args: unknown[]) => {
const date = new Date().toISOString();
console.log(
`${date} [${category}] ${level.toUpperCase()}: ${message}`,
...args,
);
},
]),
),
} as Logger;

View File

@ -1,32 +0,0 @@
class Mutex {
private locked = false;
private queue: (() => void)[] = [];
constructor(locked: boolean) {
this.locked = locked;
}
acquire() {
return new Promise<void>((resolve) => {
if (!this.locked) {
this.locked = true;
resolve();
} else {
this.queue.push(resolve);
}
});
}
release() {
if (this.queue.length > 0) {
const next = this.queue.shift();
next?.();
} else {
this.locked = false;
}
}
}
export default function mutex(locked = false) {
return new Mutex(locked);
}

View File

@ -1,13 +1,6 @@
import { readFile } from 'node:fs/promises';
import * as client from 'openid-client';
import { hp_getSingleton, hp_setSingleton } from '~server/context/global';
import { HeadplaneConfig } from '~server/context/parser';
import log from '~server/utils/log';
type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
declare global {
const __PREFIX__: string;
}
import { Configuration, IDToken, UserInfoResponse } from 'openid-client';
import log from '~/utils/log';
// We try our best to infer the callback URI of our Headplane instance
// By default it is always /<base_path>/oidc/callback
@ -21,15 +14,15 @@ export function getRedirectUri(req: Request) {
}
if (!host) {
log.error('OIDC', 'Unable to find a host header');
log.error('OIDC', 'Ensure either Host or X-Forwarded-Host is set');
log.error('auth', 'Unable to find a host header');
log.error('auth', 'Ensure either Host or X-Forwarded-Host is set');
throw new Error('Could not determine reverse proxy host');
}
const proto = req.headers.get('X-Forwarded-Proto');
if (!proto) {
log.warn('OIDC', 'No X-Forwarded-Proto header found');
log.warn('OIDC', 'Assuming your Headplane instance runs behind HTTP');
log.warn('auth', 'No X-Forwarded-Proto header found');
log.warn('auth', 'Assuming your Headplane instance runs behind HTTP');
}
url.protocol = proto ?? 'http:';
@ -37,74 +30,11 @@ export function getRedirectUri(req: Request) {
return url.href;
}
let oidcSecret: string | undefined = undefined;
export function getOidcSecret() {
return oidcSecret;
}
async function resolveClientSecret(oidc: OidcConfig) {
if (!oidc.client_secret && !oidc.client_secret_path) {
return;
}
if (oidc.client_secret_path) {
// We need to interpolate environment variables into the path
// Path formatting can be like ${ENV_NAME}/path/to/secret
let path = oidc.client_secret_path;
const matches = path.match(/\${(.*?)}/g);
if (matches) {
for (const match of matches) {
const env = match.slice(2, -1);
const value = process.env[env];
if (!value) {
log.error('CFGX', 'Environment variable %s is not set', env);
return;
}
log.debug('CFGX', 'Interpolating %s with %s', match, value);
path = path.replace(match, value);
}
}
try {
log.debug('CFGX', 'Reading client secret from %s', path);
const secret = await readFile(path, 'utf-8');
if (secret.trim().length === 0) {
log.error('CFGX', 'Empty OIDC client secret');
return;
}
oidcSecret = secret;
} catch (error) {
log.error('CFGX', 'Failed to read client secret from %s', path);
log.error('CFGX', 'Error: %s', error);
log.debug('CFGX', 'Error details: %o', error);
}
}
if (oidc.client_secret) {
oidcSecret = oidc.client_secret;
}
}
function clientAuthMethod(
method: string,
): (secret: string) => client.ClientAuth {
switch (method) {
case 'client_secret_post':
return client.ClientSecretPost;
case 'client_secret_basic':
return client.ClientSecretBasic;
case 'client_secret_jwt':
return client.ClientSecretJwt;
default:
throw new Error('Invalid client authentication method');
}
}
export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
const config = hp_getSingleton('oidc_client');
export async function beginAuthFlow(
config: Configuration,
redirect_uri: string,
token_endpoint_auth_method: string,
) {
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
@ -113,7 +43,7 @@ export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
scope: 'openid profile email',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
token_endpoint_auth_method: oidc.token_endpoint_auth_method,
token_endpoint_auth_method,
state: client.randomState(),
};
@ -134,18 +64,20 @@ export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
interface FlowOptions {
redirect_uri: string;
codeVerifier: string;
code_verifier: string;
state: string;
nonce?: string;
}
export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
const config = hp_getSingleton('oidc_client');
export async function finishAuthFlow(
config: Configuration,
options: FlowOptions,
) {
const tokens = await client.authorizationCodeGrant(
config,
new URL(options.redirect_uri),
{
pkceCodeVerifier: options.codeVerifier,
pkceCodeVerifier: options.code_verifier,
expectedNonce: options.nonce,
expectedState: options.state,
idTokenExpected: true,
@ -167,11 +99,34 @@ export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
subject: user.sub,
name: getName(user, claims),
email: user.email ?? claims.email?.toString(),
username: user.preferred_username ?? claims.preferred_username?.toString(),
username: calculateUsername(claims, user),
picture: user.picture,
};
}
function calculateUsername(claims: IDToken, user: UserInfoResponse) {
if (user.preferred_username) {
return user.preferred_username;
}
if (
claims.preferred_username &&
typeof claims.preferred_username === 'string'
) {
return claims.preferred_username;
}
if (user.email) {
return user.email.split('@')[0];
}
if (claims.email && typeof claims.email === 'string') {
return claims.email.split('@')[0];
}
return;
}
function getName(user: client.UserInfoResponse, claims: client.IDToken) {
if (user.name) {
return user.name;
@ -231,7 +186,7 @@ export function formatError(error: unknown) {
};
}
log.error('OIDC', 'Unknown error: %s', error);
log.error('auth', 'Unknown error: %s', error);
return {
code: 500,
error: {
@ -240,60 +195,3 @@ export function formatError(error: unknown) {
},
};
}
export async function testOidc(oidc: OidcConfig) {
await resolveClientSecret(oidc);
if (!oidcSecret) {
log.debug(
'OIDC',
'Cannot validate OIDC configuration without a client secret',
);
return false;
}
log.debug('OIDC', 'Discovering OIDC configuration from %s', oidc.issuer);
const secret = await resolveClientSecret(oidc);
const config = await client.discovery(
new URL(oidc.issuer),
oidc.client_id,
oidc.client_secret,
clientAuthMethod(oidc.token_endpoint_auth_method)(oidcSecret),
);
const meta = config.serverMetadata();
if (meta.authorization_endpoint === undefined) {
return false;
}
log.debug('OIDC', 'Authorization endpoint: %s', meta.authorization_endpoint);
log.debug('OIDC', 'Token endpoint: %s', meta.token_endpoint);
if (meta.response_types_supported) {
if (meta.response_types_supported.includes('code') === false) {
log.error('OIDC', 'OIDC server does not support code flow');
return false;
}
} else {
log.warn('OIDC', 'OIDC server does not advertise response_types_supported');
}
if (meta.token_endpoint_auth_methods_supported) {
if (
meta.token_endpoint_auth_methods_supported.includes(
oidc.token_endpoint_auth_method,
) === false
) {
log.error(
'OIDC',
'OIDC server does not support %s',
oidc.token_endpoint_auth_method,
);
return false;
}
}
log.debug('OIDC', 'OIDC configuration is valid');
hp_setSingleton('oidc_client', config);
return true;
}

View File

@ -7,3 +7,33 @@ export function send<T>(payload: T, init?: number | ResponseInit) {
export function send401<T>(payload: T) {
return data(payload, { status: 401 });
}
export function data400(message: string) {
return data(
{
success: false,
message,
},
{ status: 400 },
);
}
export function data403(message: string) {
return data(
{
success: false,
message,
},
{ status: 403 },
);
}
export function data404(message: string) {
return data(
{
success: false,
message,
},
{ status: 404 },
);
}

View File

@ -1,63 +0,0 @@
import { Session, createCookieSessionStorage } from 'react-router';
import { hp_getConfig } from '~server/context/global';
export type SessionData = {
hsApiKey: string;
oidc_state: string;
oidc_code_verif: string;
oidc_nonce: string;
oidc_redirect_uri: string;
agent_onboarding: boolean;
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
};
type SessionFlashData = {
error: string;
};
// TODO: Domain config in cookies
// TODO: Move this to the singleton system
const context = hp_getConfig();
const sessionStorage = createCookieSessionStorage<
SessionData,
SessionFlashData
>({
cookie: {
name: 'hp_sess',
httpOnly: true,
maxAge: 60 * 60 * 24, // 24 hours
path: '/',
sameSite: 'lax',
secrets: [context.server.cookie_secret],
secure: context.server.cookie_secure,
},
});
export function getSession(cookie: string | null) {
return sessionStorage.getSession(cookie);
}
export type ServerSession = Session<SessionData, SessionFlashData>;
export async function auth(request: Request) {
const cookie = request.headers.get('Cookie');
const session = await sessionStorage.getSession(cookie);
if (!session.has('hsApiKey')) {
return false;
}
return session;
}
export function destroySession(session: Session) {
return sessionStorage.destroySession(session);
}
export function commitSession(session: Session, opts?: { maxAge?: number }) {
return sessionStorage.commitSession(session, opts);
}

View File

@ -1,32 +0,0 @@
import { useEffect, useMemo, useRef } from 'react';
import { useFetcher } from 'react-router';
import { HostInfo } from '~/types';
export default function useAgent(nodeIds: string[], interval = 3000) {
const fetcher = useFetcher<Record<string, HostInfo>>();
const qp = useMemo(
() => new URLSearchParams({ node_ids: nodeIds.join(',') }),
[nodeIds],
);
const idRef = useRef<string[]>([]);
useEffect(() => {
if (idRef.current.join(',') !== nodeIds.join(',')) {
fetcher.load(`/api/agent?${qp.toString()}`);
idRef.current = nodeIds;
}
const intervalID = setInterval(() => {
fetcher.load(`/api/agent?${qp.toString()}`);
}, interval);
return () => {
clearInterval(intervalID);
};
}, [interval, qp]);
return {
data: fetcher.data,
isLoading: fetcher.state === 'loading',
};
}

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