Compare commits

...

55 Commits

Author SHA1 Message Date
Aarnav Tale a967731461 feat: add a temp no access page 2026-03-08 17:34:58 -04:00
Aarnav Tale a1efe36ff1 fix: rename pending approval to no access
Member role simply has no UI permissions — not a pending state.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cce57-c9e1-7732-9709-8288127573a9
2026-03-08 17:34:58 -04:00
Aarnav Tale 270b99f063 fix: show linked user in onboarding + pending approval screen
- Onboarding now shows which Headscale user was auto-linked
- Members (no ui_access) see a 'Pending Approval' page instead of
  being silently logged out — their session stays valid
- Sign out button on the pending page so users can switch accounts

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cce57-c9e1-7732-9709-8288127573a9
2026-03-08 17:34:58 -04:00
Aarnav Tale 434f886034 feat: admin UI for Headscale user linking + skip onboarding
- Add 'Link Headscale user' option in user menu (admin-only, OIDC users)
- Dialog shows only unclaimed Headscale users
- New link_user action in user-actions with claim validation
- Onboarding now allows skipping the link step with clear messaging
- Users who skip are told they can ask an admin to link later

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cce57-c9e1-7732-9709-8288127573a9
2026-03-08 17:34:57 -04:00
Aarnav Tale 684a95b5e8 fix: harden user linking and session pruning
Security:
- Add unique constraint on headscale_user_id to prevent hijacking
- linkHeadscaleUser now rejects already-claimed Headscale users
- Onboarding dropdown filters out claimed users
- onboarding-skip action redirects on rejected claims

Maintenance:
- Replace probabilistic session pruning with setInterval (15m)
- Move pruning out of request path into server startup

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cce57-c9e1-7732-9709-8288127573a9
2026-03-08 17:34:55 -04:00
Aarnav Tale 45984ec639 chore: remove deprecated oidc.user_storage_file option
The flat-file user store has been deprecated for several versions.
All user data now lives in the SQL database. Removes the config field,
nix option, docs, and migration logic.

Co-authored-by: Amp <amp@ampcode.com>
Amp-Thread-ID: https://ampcode.com/threads/T-019cce57-c9e1-7732-9709-8288127573a9
2026-03-08 17:34:49 -04:00
Aarnav Tale 0f8e192b5c feat: initial auth rework 2026-03-07 17:10:34 -05:00
Aarnav Tale 6d70497758 revert: remove this useless contribution 2026-03-07 15:15:05 -05:00
Aarnav Tale f4a2811afb chore: general cleanup/pr improvements 2026-03-07 15:12:50 -05:00
Aarnav Tale 5f2cbebbcd chore: condense/cleanup bad tests 2026-03-07 15:11:15 -05:00
Aarnav Tale da3132323c Merge pull request #480 from drifterza/fix/issue-266-owner-assignment 2026-03-07 14:52:02 -05:00
Aarnav Tale cb97b73abd Merge pull request #477 from drifterza/fix/issue-427-hostinfo-update-frequency 2026-03-07 14:51:15 -05:00
Aarnav Tale 432cd29cd0 Merge pull request #478 from drifterza/fix/issue-453-self-service-preauth-keys 2026-03-07 14:50:12 -05:00
Aarnav Tale 1feb109a36 Merge pull request #475 from drifterza/fix/issue-474-api-key-bad-request 2026-03-07 14:48:32 -05:00
Aarnav Tale f6613f1faf Merge pull request #484 from tale/update_flake_lock_action 2026-03-07 14:47:36 -05:00
Aarnav Tale 210d473bc2 Fix link to Headplane configuration file in docker.md 2026-03-07 14:47:21 -05:00
Martijn 67b1611d13 Fix link to Headplane configuration file in docker.md
Updated the link to the completed configuration file for Headplane in the Docker installation documentation.
2026-03-02 21:02:27 +01:00
Aarnav Tale df11aefd0c chore: update github actions/issues workflows 2026-03-01 13:45:36 -05:00
github-actions[bot] f9dc9d454b flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/d1c15b7' (2026-02-16)
  → 'github:nixos/nixpkgs/c0f3d81' (2026-02-27)
2026-03-01 08:33:20 +00:00
drifterza 64edd2041b fix first user owner assignment on OIDC login 2026-02-27 13:56:15 +02:00
drifterza 1462016a39 Add self-service pre-auth keys for auditor role
Closes #453
2026-02-27 12:21:16 +02:00
drifterza 779103147d add refresh interval tests 2026-02-27 11:30:19 +02:00
drifterza be79413178 fix: refresh agent HostInfo periodically using cache_ttl (#427)
Previously REFRESH was only sent once at startup, causing version info
to stay stale. Now the agent refreshes peer data at the cache_ttl
interval (default 3 minutes).
2026-02-27 11:26:06 +02:00
drifterza 787f8decbc fix: return login errors to form instead of throwing (#474)
Throwing data() in React Router v7 actions goes to ErrorBoundary instead
of returning to the component. Changed validation errors to return
directly so the login form can display them properly.
2026-02-27 10:55:42 +02:00
Aarnav Tale 0cff389051 chore: v0.6.2 2026-02-26 18:22:12 -05:00
Aarnav Tale 3c5a653942 Merge pull request #469 from drifterza/feature/oidc-pending-approval 2026-02-26 18:03:26 -05:00
drifterza 2e5bb791e8 use LiveDataProvider auto-refresh for pending approval page
Replaces manual refresh button with automatic 3-second polling.
Adds unit tests for the live data hook behavior.
2026-02-27 00:55:55 +02:00
Aarnav Tale ab3c4f7819 fix: serve favicon through the prefix 2026-02-26 01:23:02 -05:00
Aarnav Tale b2db6efd63 fix: use correct versions for the UI 2026-02-26 01:23:02 -05:00
Aarnav Tale 937bc4a667 chore: update deps 2026-02-26 01:23:02 -05:00
Aarnav Tale 9f4a7dc6e0 chore: update changelog 2026-02-26 01:23:02 -05:00
Aarnav Tale 2afc11f652 Merge pull request #466 from drifterza/feature/list-all-preauth-keys 2026-02-26 00:47:07 -05:00
drifterza 624b9ab479 list all preauth keys without user filter
headscale 0.28 added GET /v1/preauthkey without a user param
which returns all keys including tag-only ones. this change
adds getAllPreAuthKeys() and uses it when available, falling
back to per-user fetching on older versions.

the UI now handles keys where user is null, showing them as
Tag Only in the filter dropdown.

closes #432

Amp-Thread-ID: https://ampcode.com/threads/T-019c9861-2d45-73ef-ab94-5fce62f63670
Co-authored-by: Amp <amp@ampcode.com>
2026-02-26 00:40:31 -05:00
Aarnav Tale dde72ae4ed Merge pull request #465 from drifterza/feature/preauth-key-tags 2026-02-26 00:04:02 -05:00
Aarnav Tale 20c2a71713 Merge pull request #454 from z0w13/patch-1 2026-02-25 20:35:13 -05:00
Aarnav Tale 2fdbf4abea Merge pull request #467 from drifterza/feature/tag-node-operations 2026-02-25 20:34:49 -05:00
Aarnav Tale 5e0ac88189 Merge pull request #461 from tale/update_flake_lock_action 2026-02-25 20:34:28 -05:00
Aarnav Tale 4cb231deb1 Merge pull request #463 from drifterza/feature/oidc-enabled-flag 2026-02-25 20:33:34 -05:00
Aarnav Tale 2a66e02bd4 Merge pull request #462 from PriceChild/patch-1
0.6.2-beta.5 config updates in Nix
2026-02-25 20:33:26 -05:00
Aarnav Tale a385c72d90 Merge pull request #470 from drifterza/fix/preauth-keys-oidc-users
Fix pre-auth keys not showing for OIDC users without username
2026-02-25 20:31:39 -05:00
drifterza 4f59f4c77b Add user utility functions and tests for OIDC user filtering 2026-02-24 19:05:45 +02:00
drifterza 20297c6800 fix pre-auth keys not showing for oidc users without username 2026-02-24 18:40:09 +02:00
drifterza 7c39f8f3a8 add tests for role assignment upsert behavior 2026-02-24 17:39:42 +02:00
drifterza 2e6b387d67 fix role assignment for users without headplane db record 2026-02-24 17:37:09 +02:00
drifterza 5e45295523 align pending approval page styling with login page 2026-02-24 17:19:46 +02:00
drifterza a6b25b3ba3 add pending approval page for OIDC users without ui_access 2026-02-24 15:32:34 +02:00
drifterza badefc7f85 fix: handle null user for tag-only nodes in Headscale 0.28+
In Headscale 0.28+, nodes can be registered with tag-only preauth keys
which have no associated user. This change updates the Machine type to
make user optional and adds null checks throughout the codebase where
node.user is accessed.

Fixes tag node operations (rename, add tags) that previously failed
with 'Unexpected Server Error' when accessing node.user.providerId
on nodes without a user association.

Refs: #432
2026-02-24 13:32:41 +02:00
drifterza 30dd718d68 add oidc.enabled flag for helm and config generation
allows defining oidc config without enabling it
2026-02-24 10:43:28 +02:00
drifterza c96249f41e add tag-only preauth keys for headscale 0.28
allows creating keys with acl tags but no user
2026-02-24 10:25:51 +02:00
Joseph Price c60a47b968 Missing oidc options 2026-02-23 23:08:43 +00:00
Joseph Price 026b683c72 0.6.2-beta.5 config updates in Nix 2026-02-23 22:25:00 +00:00
github-actions[bot] 23df48bb2f flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/2343bbb' (2026-02-11)
  → 'github:nixos/nixpkgs/d1c15b7' (2026-02-16)
2026-02-22 08:34:42 +00:00
Aarnav Tale 9183ec2942 Merge pull request #456 from tale/update_flake_lock_action
chore: update flake.lock
2026-02-16 15:11:58 -05:00
github-actions[bot] e70319ae1a flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/ae67888' (2026-02-06)
  → 'github:nixos/nixpkgs/2343bbb' (2026-02-11)
2026-02-15 08:35:50 +00:00
Zowie 0b2ea70f33 fix(nix): use user specified package for systemd service 2026-02-14 19:19:42 +01:00
80 changed files with 8276 additions and 6045 deletions
+4
View File
@@ -1 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Question or Support
url: https://github.com/tale/headplane/discussions/new?category=q-a
about: Ask questions or get help with your setup
+45
View File
@@ -0,0 +1,45 @@
Agent:
- changed-files:
- any-glob-to-any-file:
- cmd/hp_agent/**
- internal/**
- go.mod
- go.sum
UI/UX:
- changed-files:
- any-glob-to-any-file:
- app/components/**
- app/layouts/**
- app/root.tsx
- app/tailwind.css
Authentication:
- changed-files:
- any-glob-to-any-file:
- app/server/web/**
- app/routes/auth*/**
Config:
- changed-files:
- any-glob-to-any-file:
- app/server/config/**
- internal/config/**
- config.example.yaml
Integrations:
- changed-files:
- any-glob-to-any-file:
- app/server/headscale/**
- app/openapi-*.json
Web SSH:
- changed-files:
- any-glob-to-any-file:
- cmd/hp_ssh/**
Docs:
- changed-files:
- any-glob-to-any-file:
- docs/**
- README.md
+1 -1
View File
@@ -19,7 +19,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
+2 -2
View File
@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup pnpm
uses: pnpm/action-setup@v4
@@ -56,7 +56,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Install Nix
uses: DeterminateSystems/nix-installer-action@main
+17
View File
@@ -0,0 +1,17 @@
name: Labeler
on:
pull_request_target:
types: [opened, synchronize]
permissions:
contents: read
pull-requests: write
jobs:
label:
name: Label PR
runs-on: ubuntu-latest
steps:
- uses: actions/labeler@v6
with:
sync-labels: true
+87
View File
@@ -0,0 +1,87 @@
name: Release Lifecycle
on:
push:
tags:
- "v*"
permissions:
issues: write
jobs:
beta:
name: Beta Release
if: contains(github.ref_name, '-')
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Label and comment on referenced issues
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${GITHUB_REF_NAME}"
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then
echo "No previous tag found, skipping"
exit 0
fi
echo "Processing issues referenced between ${PREV_TAG} and ${TAG}"
ISSUES=$(git log --format="%B" "${PREV_TAG}..HEAD" | grep -oE '#[0-9]+' | sort -u | tr -d '#')
for ISSUE in $ISSUES; do
[ -z "$ISSUE" ] && continue
STATE=$(gh issue view "$ISSUE" --json state --jq '.state' \
--repo "${{ github.repository }}" 2>/dev/null || echo "")
[ "$STATE" != "OPEN" ] && continue
echo "Labeling and commenting on #${ISSUE}"
gh issue edit "$ISSUE" --add-label "In Beta" \
--repo "${{ github.repository }}" 2>/dev/null || true
gh issue comment "$ISSUE" \
--body "🧪 Available in [\`${TAG}\`](https://github.com/${{ github.repository }}/releases/tag/${TAG})" \
--repo "${{ github.repository }}" 2>/dev/null || true
done
stable:
name: Stable Release
if: ${{ !contains(github.ref_name, '-') }}
runs-on: ubuntu-latest
steps:
- name: Close milestone issues
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
TAG="${GITHUB_REF_NAME}"
VERSION="${TAG#v}"
MILESTONE_NUMBER=$(gh api "repos/${{ github.repository }}/milestones" \
--jq ".[] | select(.title == \"${VERSION}\") | .number" 2>/dev/null || echo "")
if [ -z "$MILESTONE_NUMBER" ]; then
echo "No milestone found for ${VERSION}, skipping"
exit 0
fi
echo "Processing milestone ${VERSION} (#${MILESTONE_NUMBER})"
gh api "repos/${{ github.repository }}/issues?milestone=${MILESTONE_NUMBER}&state=open&per_page=100" \
--paginate --jq '.[].number' 2>/dev/null | while IFS= read -r ISSUE; do
[ -z "$ISSUE" ] && continue
echo "Closing #${ISSUE}"
gh issue edit "$ISSUE" --remove-label "In Beta" \
--repo "${{ github.repository }}" 2>/dev/null || true
gh issue comment "$ISSUE" \
--body "✅ Released in [\`${TAG}\`](https://github.com/${{ github.repository }}/releases/tag/${TAG})" \
--repo "${{ github.repository }}" 2>/dev/null || true
gh issue close "$ISSUE" \
--repo "${{ github.repository }}" 2>/dev/null || true
done
echo "Closing milestone ${VERSION}"
gh api -X PATCH "repos/${{ github.repository }}/milestones/${MILESTONE_NUMBER}" \
-f state=closed 2>/dev/null || true
-81
View File
@@ -1,81 +0,0 @@
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
id-token: write # For the attest action to push
attestations: write # For the attest action to push
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
strategy:
matrix:
include:
- target: final
tag: 'next'
- target: debug-shell
tag: 'next-shell'
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=${{ matrix.tag }}
- 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 publish ghcr.io/${{ github.repository }}:${{ matrix.tag }}
uses: docker/build-push-action@v6
id: push
with:
context: .
file: ./Dockerfile
target: ${{ matrix.target }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ matrix.tag }}
secrets: |
gh_token=${{ secrets.GITHUB_TOKEN }}
- name: Attestation Provenance for ghcr.io/${{ github.repository }}:${{ matrix.tag }}
uses: actions/attest-build-provenance@v2
id: attest
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
+4 -3
View File
@@ -23,13 +23,13 @@ jobs:
matrix:
include:
- target: final
tag_suffix: ''
tag_suffix: ""
- target: debug-shell
tag_suffix: '-shell'
tag_suffix: "-shell"
steps:
- name: Check out the repo
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Docker Metadata
id: meta
@@ -70,6 +70,7 @@ jobs:
cache-to: type=gha,mode=max
build-args: |
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
HEADPLANE_VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
- name: Attestation Provenance for ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
uses: actions/attest-build-provenance@v2
id: attest
+29
View File
@@ -0,0 +1,29 @@
name: Stale
on:
schedule:
- cron: "30 1 * * *"
workflow_dispatch:
permissions:
actions: write
issues: write
jobs:
stale:
name: Close stale issues
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
with:
stale-issue-label: Stale
only-labels: "Needs Info"
days-before-stale: 30
stale-issue-message: >
This issue has been waiting for information for 30 days.
It will be closed in 7 days if there is no further activity.
Feel free to reopen if you can provide the requested details.
close-issue-message: >
Closed due to inactivity. Feel free to reopen with the
requested information.
days-before-pr-stale: -1
days-before-pr-close: -1
+20
View File
@@ -0,0 +1,20 @@
name: Triage
on:
issues:
types: [milestoned]
permissions:
issues: write
jobs:
remove-triage:
name: Remove Needs Triage
runs-on: ubuntu-latest
steps:
- name: Remove Needs Triage label
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh issue edit ${{ github.event.issue.number }} \
--remove-label "Needs Triage" \
--repo ${{ github.repository }} 2>/dev/null || true
+53
View File
@@ -0,0 +1,53 @@
# Core Concepts
Headplane is a web application to manage Headscale, a self-hosted implementation
of the Tailscale control server. There are a few tenets that guide the entire
development of the project:
- **Simple starts**: We want to make it as easy as possible to set up and use
Headplane, while still providing powerful features for advanced users. This
means that we prioritize a clean and intuitive user interface, as well as
straightforward installation and configuration processes.
- **No breaking changes**: We want to avoid making breaking changes to the
project as much as possible. This means that we will strive to maintain
backward compatibility and provide clear migration paths when necessary.
- **Documentation**: This is the most important part of the project, without it
the entire project falls apart and is hard to use.
## Project Management
It's hard to manage this project easily, use the `gh` CLI when responding to
prompts to get context. Some common issue tags to keep track of include a
"Needs Triage", "Needs Info", "Bug", "Enhancement", and several other tags based
on what parts of the project are affected.
## Headplane Agent
The Headplane Agent is a lightweight component that runs on the same server as
Headplane and connects directly to the Tailnet in order to pull in details about
nodes that aren't available through the Headscale API such as versions, etc.
## WebSSH
This is an ephemeral WASM shim that runs in the browser and connects directly
to the Tailnet using Tailscale's go packages. It allows anyone to open up an
ephemeral machine in the Tailnet that directly SSHes into a target node.
## Build/Tooling
Headplane is a React Router 7 (framework mode) project built with Vite. Take
care to use our preferred PNPM version and Node version as defined in the
`engines` field of `package.json`. We also use TypeScript Go and Oxfmt for
type-checking and formatting respectively.
You can also run Headscale CLI commands with
`docker exec headscale headscale <command>` when the dev environment is running.
## Docs
The project has a documentation site available at the `docs/` directory built
with VitePress. The documentation is written in Markdown and can be easily
edited and extended. If making changes to staple features, please take care to
also update the documentation to reflect any changes in functionality or usage.
+13 -2
View File
@@ -1,5 +1,6 @@
# 0.6.2 (Next)
# 0.6.2 (February 26, 2026)
- **Added support for Headscale 0.28.0** including all API and data model changes.
- Added search and sortable columns to the machines list page (closes [#351](https://github.com/tale/headplane/issues/351)).
- Added support for Headscale 0.27.0 and 0.27.1
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
@@ -15,6 +16,7 @@
- Deprecated `oidc.redirect_uri` and automated callback URL detection in favor of setting `server.base_url` correctly.
- Explicitly added `oidc.use_pkce` to correctly determine PKCE configuration.
- `oidc.token_endpoint_auth_method` is now optional and will attempt to be auto-detected, defaulting to `client_secret_basic` if unavailable (closes [#410](https://github.com/tale/headplane/issues/410)).
- Added `oidc.enabled` config option to explicitly control OIDC availability (via [#463](https://github.com/tale/headplane/pull/463)).
- Removed several unnecessarily verbose or spammy log messages.
- Updated the minimum Docker API used to support the latest Docker versions (via [#370](https://github.com/tale/headplane/pull/370)).
- Enhanced the node tag dialog to show a dropdown of assignable tags (via [#362](https://github.com/tale/headplane/pull/362)).
@@ -28,8 +30,17 @@
- Added a Docker healthcheck to the container (closes [#411](https://github.com/tale/headplane/issues/411)).
- Strengthened the validation for the `/proc` integration to correctly discover the Headscale PID.
- Added lazy retry logic for OIDC providers if they initially fail to respond (closes [#423](https://github.com/tale/headplane/issues/423)).
- Fixed API key login on Headcale 0.28.0-beta.1+ (closes [#429](https://github.com/tale/headplane/issues/429)).
- Fixed API key login on Headscale 0.28.0-beta.1+ (closes [#429](https://github.com/tale/headplane/issues/429)).
- Fixed an issue that prevented the pre-auth-key UI from being usable on Headscale 0.28 and later.
- Added support for creating tag-only pre-auth keys on Headscale 0.28+ (via [#465](https://github.com/tale/headplane/pull/465)).
- Pre-auth keys are now listed without a user filter on Headscale 0.28+, with a fallback to per-user fetching on older versions (via [#466](https://github.com/tale/headplane/pull/466)).
- Fixed handling of tag-only nodes that have no user on Headscale 0.28+ (via [#467](https://github.com/tale/headplane/pull/467)).
- Adapted to the removal of Node Ownership Change in Headscale 0.28 (via [#436](https://github.com/tale/headplane/pull/436)).
- Fixed pre-auth keys not showing for OIDC users without a username (via [#470](https://github.com/tale/headplane/pull/470)).
- Fixed truncated pre-auth key display with longer Headscale 0.28 bcrypt tokens (closes [#435](https://github.com/tale/headplane/issues/435)).
- Fixed Nix systemd service to use user-specified package (via [#454](https://github.com/tale/headplane/pull/454)).
- Version displayed in the UI is now derived from git tags and build args instead of `package.json`, fixing incorrect versions shown on beta and nightly builds.
- Improved the no-access user page on the UI (via [#469](https://github.com/tale/headplane/pull/469)).
---
+2 -1
View File
@@ -37,7 +37,8 @@ COPY --from=go-base /bin/wasm_exec.js /run/app/wasm_exec.js
RUN ./build.sh --app --app-install-only
COPY . .
RUN ./build.sh --app
ARG HEADPLANE_VERSION
RUN HEADPLANE_VERSION=$HEADPLANE_VERSION ./build.sh --app
FROM gcr.io/distroless/nodejs22-debian12:latest AS final
COPY --from=js-base /run/build /app/build
+43 -42
View File
@@ -1,53 +1,54 @@
import { Outlet, redirect } from 'react-router';
import { ErrorBanner } from '~/components/error-banner';
import { pruneEphemeralNodes } from '~/server/db/pruner';
import { isDataUnauthorizedError } from '~/server/headscale/api/error-client';
import log from '~/utils/log';
import type { Route } from './+types/dashboard';
import { Outlet, redirect } from "react-router";
import { ErrorBanner } from "~/components/error-banner";
import { pruneEphemeralNodes } from "~/server/db/pruner";
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
import log from "~/utils/log";
import type { Route } from "./+types/dashboard";
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const api = context.hsApi.getRuntimeClient(session.api_key);
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
// MARK: The session should stay valid if Headscale isn't healthy
const healthy = await api.isHealthy();
if (healthy) {
try {
await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest });
} catch (error) {
if (isDataUnauthorizedError(error)) {
log.warn(
'auth',
'Logging out %s due to expired API key',
session.user.name,
);
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroySession(),
},
});
}
}
}
// MARK: The session should stay valid if Headscale isn't healthy
const healthy = await api.isHealthy();
if (healthy) {
try {
await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest });
} catch (error) {
if (isDataUnauthorizedError(error)) {
const displayName =
principal.kind === "oidc" ? principal.profile.name : principal.displayName;
log.warn("auth", "Logging out %s due to expired API key", displayName);
return redirect("/login", {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
},
});
}
}
}
return {
healthy,
};
return {
healthy,
};
}
export default function Layout() {
return (
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
);
return (
<main className="container mx-auto mt-4 mb-24 overscroll-contain">
<Outlet />
</main>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="w-fit mx-auto overscroll-contain my-24">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
return (
<div className="mx-auto my-24 w-fit overscroll-contain">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
}
+296 -99
View File
@@ -1,110 +1,307 @@
import { eq } from 'drizzle-orm';
import { CircleCheckIcon } from 'lucide-react';
import { Outlet, redirect } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import { users } from '~/server/db/schema';
import { Capabilities } from '~/server/web/roles';
import toast from '~/utils/toast';
import { Route } from './+types/shell';
import { Icon } from "@iconify/react";
import { Form, Outlet, redirect } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Footer from "~/components/Footer";
import Header from "~/components/Header";
import Link from "~/components/Link";
import Options from "~/components/Options";
import { Capabilities } from "~/server/web/roles";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
import { Route } from "./+types/shell";
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const session = await context.sessions.auth(request);
if (
typeof context.oidc === 'object' &&
session.user.subject !== 'unknown-non-oauth' &&
!request.url.endsWith('/onboarding')
) {
const [user] = await context.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
try {
const principal = await context.auth.require(request);
if (!user?.onboarded) {
return redirect('/onboarding');
}
}
if (
typeof context.oidc === "object" &&
principal.kind === "oidc" &&
!principal.user.onboarded &&
!request.url.endsWith("/onboarding")
) {
return redirect("/onboarding");
}
const api = context.hsApi.getRuntimeClient(session.api_key);
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.user,
uiAccess: check,
access: {
ui: await context.sessions.check(request, Capabilities.ui_access),
dns: await context.sessions.check(request, Capabilities.read_network),
users: await context.sessions.check(request, Capabilities.read_users),
policy: await context.sessions.check(request, Capabilities.read_policy),
machines: await context.sessions.check(
request,
Capabilities.read_machines,
),
settings: await context.sessions.check(
request,
Capabilities.read_feature,
),
},
onboarding: request.url.endsWith('/onboarding'),
healthy: await api.isHealthy(),
};
} catch {
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroySession(),
},
});
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const check = context.auth.can(principal, Capabilities.ui_access);
const noAccess = !check && principal.kind === "oidc";
const user =
principal.kind === "oidc"
? {
subject: principal.user.subject,
name: principal.profile.name,
email: principal.profile.email,
username: principal.profile.username,
picture: principal.profile.picture,
}
: { subject: "api_key", name: principal.displayName };
let linkedUserName: string | undefined;
let osValue: string | undefined;
if (noAccess && principal.kind === "oidc") {
const hsUserId = principal.user.headscaleUserId;
if (hsUserId) {
try {
const apiUsers = await api.getUsers();
const hsUser = apiUsers.find((u) => u.id === hsUserId);
linkedUserName = hsUser ? getUserDisplayName(hsUser) : undefined;
} catch {
// API unavailable, skip linked user resolution
}
}
const userAgent = request.headers.get("user-agent");
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
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;
}
}
return {
config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
debug: context.config.debug,
user,
access: {
ui: check,
dns: context.auth.can(principal, Capabilities.read_network),
users: context.auth.can(principal, Capabilities.read_users),
policy: context.auth.can(principal, Capabilities.read_policy),
machines: context.auth.can(principal, Capabilities.read_machines),
settings: context.auth.can(principal, Capabilities.read_feature),
},
onboarding: request.url.endsWith("/onboarding"),
noAccess,
linkedUserName,
osValue,
healthy: await api.isHealthy(),
};
} catch {
return redirect("/login", {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
},
});
}
}
export default function Shell({ loaderData }: Route.ComponentProps) {
return (
<>
<Header {...loaderData} />
{/* Always show the outlet if we are onboarding */}
{(loaderData.onboarding ? true : loaderData.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=${loaderData.url}`,
);
if (loaderData.noAccess && !loaderData.onboarding) {
return (
<>
<Header {...loaderData} />
<main className="container mx-auto mt-4 mb-24 overscroll-contain">
<div className="mx-auto mt-12 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
{loaderData.linkedUserName ? (
<Card className="col-span-1 mx-auto max-w-lg md:col-span-2" variant="flat">
<p className="text-sm">
Your account is linked to Headscale user{" "}
<strong>{loaderData.linkedUserName}</strong>.
</p>
</Card>
) : undefined}
<Card className="max-w-lg" variant="flat">
<Card.Title className="mb-8">
Access your network
<br />
via Tailscale
</Card.Title>
<Card.Text>
You don't have dashboard access, but you can still connect to your Headscale
network. Install Tailscale on your device to get started.
</Card.Text>
toast('Copied to clipboard');
}}
>
tailscale up --login-server={loaderData.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 {...loaderData} />
</>
);
<Options
className="my-4"
defaultSelectedKey={loaderData.osValue ?? "linux"}
label="Download Selector"
>
<Options.Item
key="linux"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="ion:terminal" />
<span>Linux</span>
</div>
}
>
<Button
className="text-md flex 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-headplane-600 dark:text-headplane-300 mt-1 text-center text-xs">
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">
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
<span>Windows</span>
</div>
}
>
<a
aria-label="Download for Windows"
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for Windows
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires Windows 10 or later.
</p>
</Options.Item>
<Options.Item
key="macos"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="streamline-logos:mac-finder-logo-solid" />
<span>macOS</span>
</div>
}
>
<a
aria-label="Download for macOS"
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for macOS
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
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">
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
<span>iOS</span>
</div>
}
>
<a
aria-label="Download for iOS"
href="https://apps.apple.com/us/app/tailscale/id1470499037"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for iOS
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires iOS 15 or later.
</p>
</Options.Item>
<Options.Item
key="android"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="material-symbols:android" />
<span>Android</span>
</div>
}
>
<a
aria-label="Download for Android"
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for Android
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires Android 8 or later.
</p>
</Options.Item>
</Options>
</Card>
<Card className="max-w-lg" variant="flat">
<div className="flex h-full flex-col justify-between">
<div>
<Card.Title className="mb-4">Need dashboard access?</Card.Title>
<Card.Text>
Your account is signed in but doesn't have permission to manage the dashboard.
Contact an administrator to request access.
</Card.Text>
</div>
<Form action="/logout" className="mt-6" method="POST">
<Button className="w-full" type="submit" variant="light">
Sign out
</Button>
</Form>
</div>
</Card>
</div>
</main>
<Footer {...loaderData} />
</>
);
}
return (
<>
<Header {...loaderData} />
<Outlet />
<Footer {...loaderData} />
</>
);
}
+56 -62
View File
@@ -1,77 +1,71 @@
import type { LinksFunction, MetaFunction } from 'react-router';
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useNavigation,
} from 'react-router';
import '@fontsource-variable/inter';
import { ExternalScripts } from 'remix-utils/external-scripts';
import ProgressBar from '~/components/ProgressBar';
import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url';
import { LiveDataProvider } from '~/utils/live-data';
import { useToastQueue } from '~/utils/toast';
import type { Route } from './+types/root';
import { ErrorBanner } from './components/error-banner';
import type { LinksFunction, MetaFunction } from "react-router";
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from "react-router";
import "@fontsource-variable/inter";
import { ExternalScripts } from "remix-utils/external-scripts";
import ProgressBar from "~/components/ProgressBar";
import ToastProvider from "~/components/ToastProvider";
import { LiveDataProvider } from "~/utils/live-data";
import { useToastQueue } from "~/utils/toast";
import type { Route } from "./+types/root";
import { ErrorBanner } from "./components/error-banner";
import stylesheet from "~/tailwind.css?url";
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
{
name: 'description',
content: 'A frontend for the headscale coordination server',
},
{ title: "Headplane" },
{
name: "description",
content: "A frontend for the headscale coordination server",
},
];
export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: stylesheet },
];
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
export function Layout({ children }: { readonly children: React.ReactNode }) {
const toastQueue = useToastQueue();
const toastQueue = useToastQueue();
// LiveDataProvider is wrapped at the top level since dialogs and things
// that control its state are usually open in portal containers which
// are not a part of the normal React tree.
return (
<LiveDataProvider>
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<Meta />
<Links />
<link href="favicon.ico" rel="icon" />
</head>
<body className="overscroll-none overflow-x-hidden dark:bg-headplane-900 dark:text-headplane-50">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
<Scripts />
<ExternalScripts />
</body>
</html>
</LiveDataProvider>
);
// LiveDataProvider is wrapped at the top level since dialogs and things
// that control its state are usually open in portal containers which
// are not a part of the normal React tree.
return (
<LiveDataProvider>
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<Meta />
<Links />
<link href={`${__PREFIX__}/favicon.ico`} rel="icon" />
</head>
<body className="dark:bg-headplane-900 dark:text-headplane-50 overflow-x-hidden overscroll-none">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
<Scripts />
<ExternalScripts />
</body>
</html>
</LiveDataProvider>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="w-screen h-screen flex items-center justify-center p-4">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
return (
<div className="flex h-screen w-screen items-center justify-center p-4">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
}
export default function App() {
const nav = useNavigation();
const nav = useNavigation();
return (
<>
<ProgressBar isVisible={nav.state === 'loading'} />
<Outlet />
</>
);
return (
<>
<ProgressBar isVisible={nav.state === "loading"} />
<Outlet />
</>
);
}
+33 -33
View File
@@ -1,41 +1,41 @@
import { index, layout, prefix, route } from '@react-router/dev/routes';
import { index, layout, prefix, route } from "@react-router/dev/routes";
export default [
// Utility Routes
index('routes/util/redirect.ts'),
route('/healthz', 'routes/util/healthz.ts'),
// Utility Routes
index("routes/util/redirect.ts"),
route("/healthz", "routes/util/healthz.ts"),
// API Routes
...prefix('/api', [route('/info', 'routes/util/info.ts')]),
// API Routes
...prefix("/api", [route("/info", "routes/util/info.ts")]),
// Authentication Routes
route('/login', 'routes/auth/login/page.tsx'),
route('/logout', 'routes/auth/logout.ts'),
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'),
route('/ssh', 'routes/ssh/console.tsx'),
// Authentication Routes
route("/login", "routes/auth/login/page.tsx"),
route("/logout", "routes/auth/logout.ts"),
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
route("/oidc/start", "routes/auth/oidc-start.ts"),
route("/ssh", "routes/ssh/console.tsx"),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
route('/onboarding', 'routes/users/onboarding.tsx'),
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'),
layout('layouts/dashboard.tsx', [
...prefix('/machines', [
index('routes/machines/overview.tsx'),
route('/:id', 'routes/machines/machine.tsx'),
]),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout("layouts/shell.tsx", [
route("/onboarding", "routes/users/onboarding.tsx"),
route("/onboarding/skip", "routes/users/onboarding-skip.tsx"),
layout("layouts/dashboard.tsx", [
...prefix("/machines", [
index("routes/machines/overview.tsx"),
route("/:id", "routes/machines/machine.tsx"),
]),
route('/users', 'routes/users/overview.tsx'),
route('/acls', 'routes/acls/overview.tsx'),
route('/dns', 'routes/dns/overview.tsx'),
route("/users", "routes/users/overview.tsx"),
route("/acls", "routes/acls/overview.tsx"),
route("/dns", "routes/dns/overview.tsx"),
...prefix('/settings', [
index('routes/settings/overview.tsx'),
route('/auth-keys', 'routes/settings/auth-keys/overview.tsx'),
route('/restrictions', 'routes/settings/restrictions/overview.tsx'),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
]),
...prefix("/settings", [
index("routes/settings/overview.tsx"),
route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
route("/restrictions", "routes/settings/restrictions/overview.tsx"),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
]),
];
+111 -121
View File
@@ -1,139 +1,129 @@
import { data } from 'react-router';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
import { data } from "react-router";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
// We only check capabilities here and assume it is writable
// If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening.
export async function aclAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_policy,
);
if (!check) {
throw data('You do not have permission to write to the ACL policy', {
status: 403,
});
}
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.write_policy);
if (!check) {
throw data("You do not have permission to write to the ACL policy", {
status: 403,
});
}
// Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData();
const policyData = formData.get('policy')?.toString();
if (!policyData) {
throw data('Missing `policy` in the form data.', {
status: 400,
});
}
// Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData();
const policyData = formData.get("policy")?.toString();
if (!policyData) {
throw data("Missing `policy` in the form data.", {
status: 400,
});
}
const api = context.hsApi.getRuntimeClient(session.api_key);
try {
const { policy, updatedAt } = await api.setPolicy(policyData);
return data({
success: true,
error: undefined,
policy,
updatedAt,
});
} catch (error) {
if (isDataWithApiError(error)) {
const rawData = error.data.rawData;
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
if (rawData.includes('update is disabled')) {
throw data('Policy is not writable', { status: 403 });
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const { policy, updatedAt } = await api.setPolicy(policyData);
return data({
success: true,
error: undefined,
policy,
updatedAt,
});
} catch (error) {
if (isDataWithApiError(error)) {
const rawData = error.data.rawData;
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
if (rawData.includes("update is disabled")) {
throw data("Policy is not writable", { status: 403 });
}
const message =
error.data.data != null &&
'message' in error.data.data &&
typeof error.data.data.message === 'string'
? error.data.data.message
: undefined;
const message =
error.data.data != null &&
"message" in error.data.data &&
typeof error.data.data.message === "string"
? error.data.data.message
: undefined;
if (message == null) {
throw error;
}
if (message == null) {
throw error;
}
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
// we need to reference other error messages based on API version.
if (context.hsApi.clientHelpers.isAtleast('0.27.0')) {
if (message.includes('parsing HuJSON:')) {
const cutIndex = message.indexOf('parsing HuJSON:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 16).trim()}`
: message;
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
// we need to reference other error messages based on API version.
if (context.hsApi.clientHelpers.isAtleast("0.27.0")) {
if (message.includes("parsing HuJSON:")) {
const cutIndex = message.indexOf("parsing HuJSON:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('parsing policy from bytes:')) {
const cutIndex = message.indexOf('parsing policy from bytes:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 26).trim()}`
: message;
if (message.includes("parsing policy from bytes:")) {
const cutIndex = message.indexOf("parsing policy from bytes:");
const trimmed =
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
} else {
// Pre-0.27.0 error messages
if (message.includes('parsing hujson')) {
const cutIndex = message.indexOf('err: hujson:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
} else {
// Pre-0.27.0 error messages
if (message.includes("parsing hujson")) {
const cutIndex = message.indexOf("err: hujson:");
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('unmarshalling policy')) {
const cutIndex = message.indexOf('err:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
if (message.includes("unmarshalling policy")) {
const cutIndex = message.indexOf("err:");
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 5)}` : message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
}
}
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
}
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}
+38 -35
View File
@@ -1,7 +1,9 @@
import { data } from 'react-router';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
import { data } from "react-router";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
// The logic for deciding policy factors is very complicated because
// there are so many factors that need to be accounted for:
@@ -11,38 +13,39 @@ import type { Route } from './+types/overview';
// If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available.
export async function aclLoader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_policy);
if (!check) {
throw data('You do not have permission to read the ACL policy.', {
status: 403,
});
}
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.read_policy);
if (!check) {
throw data("You do not have permission to read the ACL policy.", {
status: 403,
});
}
const flags = {
// Can the user write to the ACL policy
access: await context.sessions.check(request, Capabilities.write_policy),
writable: false,
policy: '',
};
const flags = {
// Can the user write to the ACL policy
access: context.auth.can(principal, Capabilities.write_policy),
writable: false,
policy: "",
};
// Try to load the ACL policy from the API.
const api = context.hsApi.getRuntimeClient(session.api_key);
try {
const { policy, updatedAt } = await api.getPolicy();
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
} catch (error) {
if (isDataWithApiError(error)) {
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
if (error.data.rawData.includes('acl policy not found')) {
flags.policy = '';
flags.writable = true;
return flags;
}
}
// Try to load the ACL policy from the API.
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const { policy, updatedAt } = await api.getPolicy();
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
} catch (error) {
if (isDataWithApiError(error)) {
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
if (error.data.rawData.includes("acl policy not found")) {
flags.policy = "";
flags.writable = true;
return flags;
}
}
throw error;
}
throw error;
}
}
+16 -16
View File
@@ -1,4 +1,4 @@
import { data, redirect } from "react-router";
import { redirect } from "react-router";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import log from "~/utils/log";
@@ -15,7 +15,10 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
"auth",
"If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly",
);
throw data("Missing `api_key`", { status: 400 });
return {
success: false,
message: "Missing API key. Please enter your API key.",
};
}
if (apiKey.length === 0) {
@@ -24,13 +27,15 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
"auth",
"If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly",
);
throw data("Received an empty `api_key`", { status: 400 });
return {
success: false,
message: "API key cannot be empty. Please enter a valid API key.",
};
}
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const apiKeys = await api.getApiKeys();
console.log(apiKeys);
// We don't need to check for 0 API keys because this request cannot
// be authenticated correctly without an API key
@@ -47,7 +52,10 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
if (lookup.expiration === null || lookup.expiration === undefined) {
log.error("auth", "Got an API key without an expiration");
throw data("API key is malformed", { status: 500 });
return {
success: false,
message: "API key is malformed (missing expiration). Please generate a new API key.",
};
}
const expiry = new Date(lookup.expiration);
@@ -58,19 +66,11 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
};
}
const expiresDays = Math.round((expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24);
return redirect("/machines", {
headers: {
"Set-Cookie": await context.sessions.createSession(
{
api_key: apiKey,
user: {
subject: "unknown-non-oauth",
name: `${lookup.prefix}...`,
email: `expires@${expiresDays.toString()}-days`,
},
},
"Set-Cookie": await context.auth.createApiKeySession(
apiKey,
`${lookup.prefix}...`,
expiry.getTime() - Date.now(),
),
},
+2 -3
View File
@@ -10,7 +10,6 @@ import Link from "~/components/Link";
import { useLiveData } from "~/utils/live-data";
import type { Route } from "./+types/page";
import { loginAction } from "./action";
import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error";
import Logout from "./logout";
@@ -18,14 +17,14 @@ import { OidcErrorNotice } from "./oidc-error";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
await context.auth.require(request);
return redirect("/machines");
} catch {}
const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined;
const oidcConnector = await context.oidcConnector?.get();
const oidcConnector = await context.oidc?.connector.get();
// MARK: This works because the OIDC connector will always return false
// for `isExclusive` if the OIDC config isn't usable.
+18 -22
View File
@@ -1,29 +1,25 @@
import { type ActionFunctionArgs, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { type ActionFunctionArgs, redirect } from "react-router";
import type { LoadContext } from "~/server";
export async function loader() {
return redirect('/machines');
return redirect("/machines");
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
try {
await context.sessions.auth(request);
} catch {
redirect('/login');
}
export async function action({ request, context }: ActionFunctionArgs<LoadContext>) {
try {
await context.auth.require(request);
} catch {
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';
// 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 context.sessions.destroySession(),
},
});
return redirect(url, {
headers: {
"Set-Cookie": await context.auth.destroySession(request),
},
});
}
+19 -26
View File
@@ -1,18 +1,16 @@
import { count, eq } from "drizzle-orm";
import { createHash } from "node:crypto";
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { ulid } from "ulidx";
import { users } from "~/server/db/schema";
import { Roles } from "~/server/web/roles";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import log from "~/utils/log";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-callback";
export async function loader({ request, context }: Route.LoaderArgs) {
const oidcConnector = await context.oidcConnector?.get();
const oidcConnector = await context.oidc?.connector.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
@@ -82,31 +80,26 @@ export async function loader({ request, context }: Route.LoaderArgs) {
})()
: userInfo.picture;
const [{ count: userCount }] = await context.db
.select({ count: count() })
.from(users)
.where(eq(users.caps, Roles.owner));
const userId = await context.auth.findOrCreateUser(claims.sub);
await context.db
.insert(users)
.values({
id: ulid(),
sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
try {
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
const hsUsers = await hsApi.getUsers();
const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email);
if (hsUser) {
await context.auth.linkHeadscaleUser(userId, hsUser.id);
}
} catch (error) {
log.warn("auth", "Failed to link Headscale user: %s", String(error));
}
return redirect("/", {
headers: {
"Set-Cookie": await context.sessions.createSession({
api_key: oidcConnector.apiKey,
user: {
subject: claims.sub,
username,
name,
email: userInfo.email,
picture,
},
"Set-Cookie": await context.auth.createOidcSession(userId, {
name,
email: userInfo.email,
username,
picture,
}),
},
});
+2 -2
View File
@@ -8,11 +8,11 @@ import type { Route } from "./+types/oidc-start";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
await context.auth.require(request);
return redirect("/");
} catch {}
const oidcConnector = await context.oidcConnector?.get();
const oidcConnector = await context.oidc?.connector.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
+194 -194
View File
@@ -1,231 +1,231 @@
import { data } from 'react-router';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
import { data } from "react-router";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function dnsAction({ request, context }: Route.ActionArgs) {
const check = await context.sessions.check(
request,
Capabilities.write_network,
);
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.write_network);
if (!check) {
return data({ success: false }, 403);
}
if (!check) {
return data({ success: false }, 403);
}
if (!context.hs.writable()) {
return data({ success: false }, 403);
}
if (!context.hs.writable()) {
return data({ success: false }, 403);
}
// We only need it for health checks which don't require auth
const api = context.hsApi.getRuntimeClient('fake-api-key');
// We only need it for health checks which don't require auth
const api = context.hsApi.getRuntimeClient("fake-api-key");
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
return data({ success: false }, 400);
}
const formData = await request.formData();
const action = formData.get("action_id")?.toString();
if (!action) {
return data({ success: false }, 400);
}
switch (action) {
case 'rename_tailnet': {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
switch (action) {
case "rename_tailnet": {
const newName = formData.get("new_name")?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.base_domain',
value: newName,
},
]);
await context.hs.patch([
{
path: "dns.base_domain",
value: newName,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Tailnet renamed successfully' };
}
case 'toggle_magic': {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await context.integration?.onConfigChange(api);
return { message: "Tailnet renamed successfully" };
}
case "toggle_magic": {
const newState = formData.get("new_state")?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
await context.hs.patch([
{
path: "dns.magic_dns",
value: newState === "enabled",
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Magic DNS state updated successfully' };
}
case 'remove_ns': {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
await context.integration?.onConfigChange(api);
return { message: "Magic DNS state updated successfully" };
}
case "remove_ns": {
const config = context.hs.c!;
const ns = formData.get("ns")?.toString();
const splitName = formData.get("split_name")?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
if (splitName === "global") {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
{
path: "dns.nameservers.global",
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers.length > 0 ? servers : null,
},
]);
}
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers.length > 0 ? servers : null,
},
]);
}
await context.integration?.onConfigChange(api);
return { message: 'Nameserver removed successfully' };
}
case 'add_ns': {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
await context.integration?.onConfigChange(api);
return { message: "Nameserver removed successfully" };
}
case "add_ns": {
const config = context.hs.c!;
const ns = formData.get("ns")?.toString();
const splitName = formData.get("split_name")?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
if (splitName === "global") {
const servers = config.dns.nameservers.global;
servers.push(ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await context.hs.patch([
{
path: "dns.nameservers.global",
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await context.integration?.onConfigChange(api);
return { message: 'Nameserver added successfully' };
}
case 'remove_domain': {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
await context.integration?.onConfigChange(api);
return { message: "Nameserver added successfully" };
}
case "remove_domain": {
const config = context.hs.c!;
const domain = formData.get("domain")?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
{
path: "dns.search_domains",
value: domains,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Domain removed successfully' };
}
case 'add_domain': {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
await context.integration?.onConfigChange(api);
return { message: "Domain removed successfully" };
}
case "add_domain": {
const config = context.hs.c!;
const domain = formData.get("domain")?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains;
domains.push(domain);
const domains = config.dns.search_domains;
domains.push(domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.hs.patch([
{
path: "dns.search_domains",
value: domains,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Domain added successfully' };
}
case 'remove_record': {
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
await context.integration?.onConfigChange(api);
return { message: "Domain added successfully" };
}
case "remove_record": {
const recordName = formData.get("record_name")?.toString();
const recordType = formData.get("record_type")?.toString();
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
// Value is not needed for removal
const restart = await context.hs.removeDNS({
name: recordName,
type: recordType,
value: '',
});
// Value is not needed for removal
const restart = await context.hs.removeDNS({
name: recordName,
type: recordType,
value: "",
});
if (!restart) {
return;
}
if (!restart) {
return;
}
await context.integration?.onConfigChange(api);
return { message: 'DNS record removed successfully' };
}
case 'add_record': {
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
await context.integration?.onConfigChange(api);
return { message: "DNS record removed successfully" };
}
case "add_record": {
const recordName = formData.get("record_name")?.toString();
const recordType = formData.get("record_type")?.toString();
const recordValue = formData.get("record_value")?.toString();
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
const restart = await context.hs.addDNS({
name: recordName,
type: recordType,
value: recordValue,
});
const restart = await context.hs.addDNS({
name: recordName,
type: recordType,
value: recordValue,
});
if (!restart) {
return;
}
if (!restart) {
return;
}
await context.integration?.onConfigChange(api);
return { message: 'DNS record added successfully' };
}
case 'override_dns': {
const override = formData.get('override_dns')?.toString();
if (!override) {
return data({ success: false }, 400);
}
await context.integration?.onConfigChange(api);
return { message: "DNS record added successfully" };
}
case "override_dns": {
const override = formData.get("override_dns")?.toString();
if (!override) {
return data({ success: false }, 400);
}
const overrideValue = override === 'true';
await context.hs.patch([
{
path: 'dns.override_local_dns',
value: overrideValue,
},
]);
const overrideValue = override === "true";
await context.hs.patch([
{
path: "dns.override_local_dns",
value: overrideValue,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'DNS override updated successfully' };
}
default:
return data({ success: false }, 400);
}
await context.integration?.onConfigChange(api);
return { message: "DNS override updated successfully" };
}
default:
return data({ success: false }, 400);
}
}
+86 -98
View File
@@ -1,115 +1,103 @@
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import Code from '~/components/Code';
import Notice from '~/components/Notice';
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';
import RenameTailnet from './components/rename-tailnet';
import ToggleMagic from './components/toggle-magic';
import { dnsAction } from './dns-actions';
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
import { useLoaderData } from "react-router";
import Code from "~/components/Code";
import Notice from "~/components/Notice";
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";
import RenameTailnet from "./components/rename-tailnet";
import ToggleMagic from "./components/toggle-magic";
import { dnsAction } from "./dns-actions";
// We do not want to expose every config value
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.hs.readable()) {
throw new Error('No configuration is available');
}
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 principal = await context.auth.require(request);
const check = context.auth.can(principal, 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 writablePermission = context.auth.can(principal, Capabilities.write_network);
const config = context.hs.c!;
const dns = {
prefixes: config.prefixes,
magicDns: config.dns.magic_dns,
baseDomain: config.dns.base_domain,
nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
overrideDns: config.dns.override_local_dns,
extraRecords: context.hs.d,
};
const config = context.hs.c!;
const dns = {
prefixes: config.prefixes,
magicDns: config.dns.magic_dns,
baseDomain: config.dns.base_domain,
nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
overrideDns: config.dns.override_local_dns,
extraRecords: context.hs.d,
};
return {
...dns,
access: writablePermission,
writable: context.hs.writable(),
};
return {
...dns,
access: writablePermission,
writable: context.hs.writable(),
};
}
export async function action(data: ActionFunctionArgs) {
return dnsAction(data);
return dnsAction(data);
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const data = useLoaderData<typeof loader>();
const allNs: Record<string, string[]> = {};
for (const key of Object.keys(data.splitDns)) {
allNs[key] = data.splitDns[key];
}
const allNs: Record<string, string[]> = {};
for (const key of Object.keys(data.splitDns)) {
allNs[key] = data.splitDns[key];
}
allNs.global = data.nameservers;
const isDisabled = data.access === false || data.writable === false;
allNs.global = data.nameservers;
const isDisabled = data.access === false || data.writable === false;
return (
<div className="flex flex-col gap-16 max-w-(--breakpoint-lg)">
{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 isDisabled={isDisabled} name={data.baseDomain} />
<ManageNS
isDisabled={isDisabled}
nameservers={allNs}
overrideLocalDns={data.overrideDns}
/>
<ManageRecords isDisabled={isDisabled} records={data.extraRecords} />
<ManageDomains
isDisabled={isDisabled}
magic={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
/>
return (
<div className="flex max-w-(--breakpoint-lg) flex-col gap-16">
{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 isDisabled={isDisabled} name={data.baseDomain} />
<ManageNS isDisabled={isDisabled} nameservers={allNs} overrideLocalDns={data.overrideDns} />
<ManageRecords isDisabled={isDisabled} records={data.extraRecords} />
<ManageDomains
isDisabled={isDisabled}
magic={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
/>
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="mb-4">
Automatically register domain names for each device on the tailnet.
Devices will be accessible at{' '}
<Code>
[device].
{data.baseDomain}
</Code>{' '}
when Magic DNS is enabled.
</p>
<ToggleMagic isDisabled={isDisabled} isEnabled={data.magicDns} />
</div>
</div>
);
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">Magic DNS</h1>
<p className="mb-4">
Automatically register domain names for each device on the tailnet. Devices will be
accessible at{" "}
<Code>
[device].
{data.baseDomain}
</Code>{" "}
when Magic DNS is enabled.
</p>
<ToggleMagic isDisabled={isDisabled} isEnabled={data.magicDns} />
</div>
</div>
);
}
@@ -2,8 +2,6 @@ import { ChevronDown, Copy } from "lucide-react";
import { useMemo } from "react";
import { Link } from "react-router";
import type { User } from "~/types";
import Chip from "~/components/Chip";
import Menu from "~/components/Menu";
import StatusCircle from "~/components/StatusCircle";
@@ -12,11 +10,13 @@ import { ExpiryTag } from "~/components/tags/Expiry";
import { HeadplaneAgentTag } from "~/components/tags/HeadplaneAgent";
import { SubnetTag } from "~/components/tags/Subnet";
import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
import type { User } from "~/types";
import cn from "~/utils/cn";
import * as hinfo from "~/utils/host-info";
import { PopulatedNode } from "~/utils/node-info";
import { formatTimeDelta } from "~/utils/time";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
import MenuOptions from "./menu";
@@ -63,7 +63,7 @@ export default function MachineRow({
{node.givenName}
</p>
<p className="text-sm opacity-50">
{node.user.name || node.user.displayName || node.user.email || node.user.id}
{node.user ? getUserDisplayName(node.user) : "Tag-owned"}
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{mapTagsToComponents(node, uiTags)}
+36 -38
View File
@@ -1,45 +1,43 @@
import { Key, useState } from 'react';
import Dialog from '~/components/Dialog';
import Select from '~/components/Select';
import type { Machine, User } from '~/types';
import { Key, useState } from "react";
import Dialog from "~/components/Dialog";
import Select from "~/components/Select";
import type { Machine, User } from "~/types";
import { getUserDisplayName } from "~/utils/user";
interface MoveProps {
machine: Machine;
users: User[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
machine: Machine;
users: User[];
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
const [userId, setUserId] = useState<Key | null>(machine.user.id);
const [userId, setUserId] = useState<Key | null>(machine.user?.id ?? null);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel isDisabled={userId === machine.user.id}>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
</Dialog.Text>
<input name="action_id" type="hidden" value="reassign" />
<input name="node_id" type="hidden" value={machine.id} />
<input name="user_id" type="hidden" value={userId?.toString()} />
<Select
defaultSelectedKey={machine.user.id}
isRequired
label="Owner"
name="user"
onSelectionChange={(key) => {
setUserId(key);
}}
placeholder="Select a user"
>
{users.map((user) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel isDisabled={userId === machine.user?.id}>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>The owner of the machine is the user associated with it.</Dialog.Text>
<input name="action_id" type="hidden" value="reassign" />
<input name="node_id" type="hidden" value={machine.id} />
<input name="user_id" type="hidden" value={userId?.toString()} />
<Select
defaultSelectedKey={machine.user?.id}
isRequired
label="Owner"
name="user"
onSelectionChange={(key) => {
setUserId(key);
}}
placeholder="Select a user"
>
{users.map((user) => (
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
);
}
+77 -83
View File
@@ -1,92 +1,86 @@
import { Computer, FileKey2 } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Menu from '~/components/Menu';
import Select from '~/components/Select';
import type { User } from '~/types';
import { Computer, FileKey2 } from "lucide-react";
import { useState } from "react";
import { useNavigate } from "react-router";
import Code from "~/components/Code";
import Dialog from "~/components/Dialog";
import Input from "~/components/Input";
import Menu from "~/components/Menu";
import Select from "~/components/Select";
import type { User } from "~/types";
import { getUserDisplayName } from "~/utils/user";
export interface NewMachineProps {
server: string;
users: User[];
isDisabled?: boolean;
disabledKeys?: string[];
server: string;
users: User[];
isDisabled?: boolean;
disabledKeys?: string[];
}
export default function NewMachine(data: NewMachineProps) {
const [pushDialog, setPushDialog] = useState(false);
const [mkey, setMkey] = useState('');
const navigate = useNavigate();
const [pushDialog, setPushDialog] = useState(false);
const [mkey, setMkey] = useState("");
const navigate = useNavigate();
const isMkeyInvalid = mkey.length > 0 && mkey.length !== 24;
const isMkeyInvalid = mkey.length > 0 && mkey.length !== 24;
return (
<>
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
<Dialog.Panel isDisabled={mkey.length !== 24}>
<Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4">
The machine key is given when you run{' '}
<Code isCopyable>tailscale up --login-server={data.server}</Code> on
your device.
</Dialog.Text>
<input name="action_id" type="hidden" value="register" />
<Input
errorMessage="Machine key must be exactly 24 characters"
isInvalid={isMkeyInvalid}
isRequired
label="Machine Key"
name="register_key"
onChange={setMkey}
placeholder="AbCd..."
validationBehavior="native"
/>
<Select
isRequired
label="Owner"
name="user"
placeholder="Select a user"
>
{data.users.map((user) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
<Menu disabledKeys={data.disabledKeys} isDisabled={data.isDisabled}>
<Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel
onAction={(key) => {
if (key === 'register') {
setPushDialog(true);
return;
}
return (
<>
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
<Dialog.Panel isDisabled={mkey.length !== 24}>
<Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4">
The machine key is given when you run{" "}
<Code isCopyable>tailscale up --login-server={data.server}</Code> on your device.
</Dialog.Text>
<input name="action_id" type="hidden" value="register" />
<Input
errorMessage="Machine key must be exactly 24 characters"
isInvalid={isMkeyInvalid}
isRequired
label="Machine Key"
name="register_key"
onChange={setMkey}
placeholder="AbCd..."
validationBehavior="native"
/>
<Select isRequired label="Owner" name="user" placeholder="Select a user">
{data.users.map((user) => (
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
<Menu disabledKeys={data.disabledKeys} isDisabled={data.isDisabled}>
<Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel
onAction={(key) => {
if (key === "register") {
setPushDialog(true);
return;
}
if (key === 'pre-auth') {
navigate('/settings/auth-keys');
}
}}
>
<Menu.Section>
<Menu.Item key="register" textValue="Register Machine Key">
<div className="flex items-center gap-x-3">
<Computer className="w-4" />
Register Machine Key
</div>
</Menu.Item>
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
<div className="flex items-center gap-x-3">
<FileKey2 className="w-4" />
Generate Pre-auth Key
</div>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
</>
);
if (key === "pre-auth") {
navigate("/settings/auth-keys");
}
}}
>
<Menu.Section>
<Menu.Item key="register" textValue="Register Machine Key">
<div className="flex items-center gap-x-3">
<Computer className="w-4" />
Register Machine Key
</div>
</Menu.Item>
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
<div className="flex items-center gap-x-3">
<FileKey2 className="w-4" />
Generate Pre-auth Key
</div>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
</>
);
}
+6 -5
View File
@@ -6,11 +6,12 @@ import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/machine";
export async function machineAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.write_machines);
const principal = await context.auth.require(request);
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(session.api_key);
const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const action = formData.get("action_id")?.toString();
if (!action) {
@@ -21,7 +22,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
// Fast track register since it doesn't require an existing machine
if (action === "register") {
if (!check) {
if (!context.auth.can(principal, Capabilities.write_machines)) {
throw data("You do not have permission to manage machines", {
status: 403,
});
@@ -60,7 +61,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
});
}
if (node.user.providerId?.split("/").pop() !== session.user.subject && !check) {
if (!context.auth.canManageNode(principal, node)) {
throw data("You do not have permission to act on this machine", {
status: 403,
});
+8 -6
View File
@@ -12,16 +12,16 @@ import Tooltip from "~/components/Tooltip";
import cn from "~/utils/cn";
import { getOSInfo, getTSVersion } from "~/utils/host-info";
import { mapNodes, sortNodeTags } from "~/utils/node-info";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/machine";
import { mapTagsToComponents, uiTagsForNode } from "./components/machine-row";
import MenuOptions from "./components/menu";
import Routes from "./dialogs/routes";
import { machineAction } from "./machine-actions";
export async function loader({ request, params, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const principal = await context.auth.require(request);
if (!params.id) {
throw new Error("No machine ID provided");
}
@@ -37,14 +37,16 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
}
}
const api = context.hsApi.getRuntimeClient(session.api_key);
const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
const node = nodes.find((node) => node.id === params.id);
const lookup = await context.agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup);
const tags = [...node.tags].sort();
const supportsNodeOwnerChange = ! context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
return {
node: enhancedNode,
@@ -109,7 +111,7 @@ export default function Page({
</span>
<div className="mt-1 flex items-center gap-x-2.5">
<UserCircle />
{node.user.name || node.user.displayName || node.user.email || node.user.id}
{node.user ? getUserDisplayName(node.user) : "Tag-owned"}
</div>
</div>
<div className="p-2 pl-4">
@@ -241,7 +243,7 @@ export default function Page({
<div className="flex flex-col gap-1">
<Attribute
name="Creator"
value={node.user.name || node.user.displayName || node.user.email || node.user.id}
value={node.user ? getUserDisplayName(node.user) : "Tag-owned"}
/>
<Attribute name="Machine name" value={node.givenName} />
<Attribute
+350 -369
View File
@@ -1,394 +1,375 @@
import { ChevronDown, ChevronUp, Info, X } from 'lucide-react';
import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Input from '~/components/Input';
import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip';
import { Capabilities } from '~/server/web/roles';
import cn from '~/utils/cn';
import { mapNodes, sortNodeTags } from '~/utils/node-info';
import type { Route } from './+types/overview';
import MachineRow from './components/machine-row';
import NewMachine from './dialogs/new';
import { machineAction } from './machine-actions';
import { ChevronDown, ChevronUp, Info, X } from "lucide-react";
import { useMemo, useState } from "react";
import Code from "~/components/Code";
import Input from "~/components/Input";
import Link from "~/components/Link";
import Tooltip from "~/components/Tooltip";
import { Capabilities } from "~/server/web/roles";
import cn from "~/utils/cn";
import { mapNodes, sortNodeTags } from "~/utils/node-info";
import type { Route } from "./+types/overview";
import MachineRow from "./components/machine-row";
import NewMachine from "./dialogs/new";
import { machineAction } from "./machine-actions";
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const user = session.user;
if (!user) {
throw new Error('Missing user session. Please log in again.');
}
const principal = await context.auth.require(request);
const check = await context.sessions.check(
request,
Capabilities.read_machines,
);
if (!context.auth.can(principal, Capabilities.read_machines)) {
throw new Error(
"You do not have permission to view this page. Please contact your administrator.",
);
}
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 = context.auth.can(principal, Capabilities.write_machines);
const writablePermission = await context.sessions.check(
request,
Capabilities.write_machines,
);
const api = context.hsApi.getRuntimeClient(
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = ! context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
return {
populatedNodes,
nodes,
users,
magic,
server: context.config.headscale.url,
publicServer: context.config.headscale.public_url,
agent: context.agents?.agentID(),
writable: writablePermission,
preAuth: await context.sessions.check(
request,
Capabilities.generate_authkeys,
),
subject: user.subject,
supportsNodeOwnerChange: supportsNodeOwnerChange,
};
return {
populatedNodes,
nodes,
users,
magic,
server: context.config.headscale.url,
publicServer: context.config.headscale.public_url,
agent: context.agents?.agentID(),
writable: writablePermission,
preAuth: context.auth.can(principal, Capabilities.generate_authkeys),
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
supportsNodeOwnerChange: supportsNodeOwnerChange,
};
}
export const action = machineAction;
type SortField = 'name' | 'ip' | 'version' | 'lastSeen';
type SortField = "name" | "ip" | "version" | "lastSeen";
export default function Page({ loaderData }: Route.ComponentProps) {
const [searchQuery, setSearchQuery] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [searchQuery, setSearchQuery] = useState("");
const [sortField, setSortField] = useState<SortField>("name");
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
const filteredAndSortedNodes = useMemo(() => {
const query = searchQuery.toLowerCase().trim();
const filteredAndSortedNodes = useMemo(() => {
const query = searchQuery.toLowerCase().trim();
let nodes = loaderData.populatedNodes.filter((node) => {
if (!query) return true;
if (node.givenName.toLowerCase().includes(query)) return true;
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query)))
return true;
return false;
});
let nodes = loaderData.populatedNodes.filter((node) => {
if (!query) return true;
if (node.givenName.toLowerCase().includes(query)) return true;
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) return true;
return false;
});
nodes = [...nodes].sort((a, b) => {
let comparison = 0;
nodes = [...nodes].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case 'name':
comparison = a.givenName.localeCompare(b.givenName);
break;
case 'ip': {
const getIPv4 = (addresses: string[]) =>
addresses.find((ip) => !ip.includes(':')) || addresses[0] || '';
const ipA = getIPv4(a.ipAddresses);
const ipB = getIPv4(b.ipAddresses);
switch (sortField) {
case "name":
comparison = a.givenName.localeCompare(b.givenName);
break;
case "ip": {
const getIPv4 = (addresses: string[]) =>
addresses.find((ip) => !ip.includes(":")) || addresses[0] || "";
const ipA = getIPv4(a.ipAddresses);
const ipB = getIPv4(b.ipAddresses);
if (!ipA.includes(':') && !ipB.includes(':')) {
const octetsA = ipA.split('.').map(Number);
const octetsB = ipB.split('.').map(Number);
for (let i = 0; i < 4; i++) {
if (octetsA[i] !== octetsB[i]) {
comparison = octetsA[i] - octetsB[i];
break;
}
}
} else {
comparison = ipA.localeCompare(ipB);
}
break;
}
case 'version': {
const versionA = a.hostInfo?.IPNVersion?.split('-')[0] || '0';
const versionB = b.hostInfo?.IPNVersion?.split('-')[0] || '0';
const partsA = versionA.split('.').map(Number);
const partsB = versionB.split('.').map(Number);
const maxLen = Math.max(partsA.length, partsB.length);
if (!ipA.includes(":") && !ipB.includes(":")) {
const octetsA = ipA.split(".").map(Number);
const octetsB = ipB.split(".").map(Number);
for (let i = 0; i < 4; i++) {
if (octetsA[i] !== octetsB[i]) {
comparison = octetsA[i] - octetsB[i];
break;
}
}
} else {
comparison = ipA.localeCompare(ipB);
}
break;
}
case "version": {
const versionA = a.hostInfo?.IPNVersion?.split("-")[0] || "0";
const versionB = b.hostInfo?.IPNVersion?.split("-")[0] || "0";
const partsA = versionA.split(".").map(Number);
const partsB = versionB.split(".").map(Number);
const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) {
const segA = partsA[i] || 0;
const segB = partsB[i] || 0;
if (segA !== segB) {
comparison = segA - segB;
break;
}
}
break;
}
case 'lastSeen':
if (a.online !== b.online) {
comparison = a.online ? 1 : -1;
break;
}
comparison =
new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime();
break;
}
for (let i = 0; i < maxLen; i++) {
const segA = partsA[i] || 0;
const segB = partsB[i] || 0;
if (segA !== segB) {
comparison = segA - segB;
break;
}
}
break;
}
case "lastSeen":
if (a.online !== b.online) {
comparison = a.online ? 1 : -1;
break;
}
comparison = new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime();
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
return sortDirection === "asc" ? comparison : -comparison;
});
return nodes;
}, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]);
return nodes;
}, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('asc');
}
};
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
} else {
setSortField(field);
setSortDirection("asc");
}
};
return (
<>
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<div className="flex flex-col">
<h1 className="text-2xl font-medium mb-2">Machines</h1>
<p>
Manage the devices connected to your Tailnet.{' '}
<Link
name="Tailscale Manage Devices Documentation"
to="https://tailscale.com/kb/1372/manage-devices"
>
Learn more
</Link>
</p>
</div>
<NewMachine
disabledKeys={loaderData.preAuth ? [] : ['pre-auth']}
isDisabled={!loaderData.writable}
server={loaderData.publicServer ?? loaderData.server}
users={loaderData.users}
/>
</div>
<div className="mb-4 flex items-center gap-4">
<div className="relative w-64">
<Input
label="Search machines"
labelHidden
maxLength={100}
onChange={(value) => setSearchQuery(value.slice(0, 100))}
placeholder="Search by name or IP address..."
value={searchQuery}
/>
{searchQuery && (
<button
aria-label="Clear search"
className={cn(
'absolute right-2 top-1/2 -translate-y-1/2',
'p-1 rounded-full',
'text-headplane-400 hover:text-headplane-600',
'dark:text-headplane-500 dark:hover:text-headplane-300',
'hover:bg-headplane-100 dark:hover:bg-headplane-800',
)}
onClick={() => setSearchQuery('')}
type="button"
>
<X className="w-4 h-4" />
</button>
)}
</div>
<span className="text-sm text-headplane-500 whitespace-nowrap">
{searchQuery
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
: `${loaderData.populatedNodes.length} machines`}
</span>
</div>
<div className="overflow-x-auto">
<table className="table-auto w-full rounded-lg min-w-[640px]">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th
aria-sort={
sortField === 'name'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by name"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('name')}
type="button"
>
Name
{sortField === 'name' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
<th
aria-sort={
sortField === 'ip'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="pb-2 w-1/4"
>
<div className="flex items-center gap-x-1">
<button
aria-label="Sort by IP address"
className={cn(
'flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('ip')}
type="button"
>
Addresses
{sortField === 'ip' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
{loaderData.magic ? (
<Tooltip>
<Info className="w-4 h-4" />
<Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based
on their name and also at{' '}
<Code>
[name].
{loaderData.magic}
</Code>
</Tooltip.Body>
</Tooltip>
) : undefined}
</div>
</th>
{/* We only want to show the version column if there are agents */}
{loaderData.agent !== undefined ? (
<th
aria-sort={
sortField === 'version'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by version"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('version')}
type="button"
>
Version
{sortField === 'version' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
) : undefined}
<th
aria-sort={
sortField === 'lastSeen'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by last seen"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('lastSeen')}
type="button"
>
Last Seen
{sortField === 'lastSeen' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{filteredAndSortedNodes.length === 0 ? (
<tr>
<td
className="py-8 text-center text-headplane-500"
colSpan={loaderData.agent !== undefined ? 5 : 4}
>
No machines found matching "{searchQuery}"
</td>
</tr>
) : (
filteredAndSortedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
isAgent={
loaderData.agent
? loaderData.agent === node.nodeKey
: undefined
}
isDisabled={
loaderData.writable
? false // If the user has write permissions, they can edit all machines
: node.user.providerId?.split('/').pop() !==
loaderData.subject
}
key={node.id}
magic={loaderData.magic}
node={node}
users={loaderData.users}
supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange}
/>
))
)}
</tbody>
</table>
</div>
</>
);
return (
<>
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col">
<h1 className="mb-2 text-2xl font-medium">Machines</h1>
<p>
Manage the devices connected to your Tailnet.{" "}
<Link
name="Tailscale Manage Devices Documentation"
to="https://tailscale.com/kb/1372/manage-devices"
>
Learn more
</Link>
</p>
</div>
<NewMachine
disabledKeys={loaderData.preAuth ? [] : ["pre-auth"]}
isDisabled={!loaderData.writable}
server={loaderData.publicServer ?? loaderData.server}
users={loaderData.users}
/>
</div>
<div className="mb-4 flex items-center gap-4">
<div className="relative w-64">
<Input
label="Search machines"
labelHidden
maxLength={100}
onChange={(value) => setSearchQuery(value.slice(0, 100))}
placeholder="Search by name or IP address..."
value={searchQuery}
/>
{searchQuery && (
<button
aria-label="Clear search"
className={cn(
"absolute right-2 top-1/2 -translate-y-1/2",
"p-1 rounded-full",
"text-headplane-400 hover:text-headplane-600",
"dark:text-headplane-500 dark:hover:text-headplane-300",
"hover:bg-headplane-100 dark:hover:bg-headplane-800",
)}
onClick={() => setSearchQuery("")}
type="button"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<span className="text-headplane-500 text-sm whitespace-nowrap">
{searchQuery
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
: `${loaderData.populatedNodes.length} machines`}
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] table-auto rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="px-0.5 text-left">
<th
aria-sort={
sortField === "name"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="pb-2 text-xs font-bold uppercase"
>
<button
aria-label="Sort by name"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-headplane-900 dark:hover:text-headplane-100",
)}
onClick={() => handleSort("name")}
type="button"
>
Name
{sortField === "name" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
</th>
<th
aria-sort={
sortField === "ip"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="w-1/4 pb-2"
>
<div className="flex items-center gap-x-1">
<button
aria-label="Sort by IP address"
className={cn(
"flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold",
"hover:text-headplane-900 dark:hover:text-headplane-100",
)}
onClick={() => handleSort("ip")}
type="button"
>
Addresses
{sortField === "ip" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
{loaderData.magic ? (
<Tooltip>
<Info className="h-4 w-4" />
<Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based on their name and
also at{" "}
<Code>
[name].
{loaderData.magic}
</Code>
</Tooltip.Body>
</Tooltip>
) : undefined}
</div>
</th>
{/* We only want to show the version column if there are agents */}
{loaderData.agent !== undefined ? (
<th
aria-sort={
sortField === "version"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="pb-2 text-xs font-bold uppercase"
>
<button
aria-label="Sort by version"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-headplane-900 dark:hover:text-headplane-100",
)}
onClick={() => handleSort("version")}
type="button"
>
Version
{sortField === "version" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
</th>
) : undefined}
<th
aria-sort={
sortField === "lastSeen"
? sortDirection === "asc"
? "ascending"
: "descending"
: "none"
}
className="pb-2 text-xs font-bold uppercase"
>
<button
aria-label="Sort by last seen"
className={cn(
"flex items-center gap-x-1 cursor-pointer",
"hover:text-headplane-900 dark:hover:text-headplane-100",
)}
onClick={() => handleSort("lastSeen")}
type="button"
>
Last Seen
{sortField === "lastSeen" &&
(sortDirection === "asc" ? (
<ChevronUp className="h-3 w-3" />
) : (
<ChevronDown className="h-3 w-3" />
))}
</button>
</th>
</tr>
</thead>
<tbody
className={cn(
"divide-y divide-headplane-100 dark:divide-headplane-800 align-top",
"border-t border-headplane-100 dark:border-headplane-800",
)}
>
{filteredAndSortedNodes.length === 0 ? (
<tr>
<td
className="text-headplane-500 py-8 text-center"
colSpan={loaderData.agent !== undefined ? 5 : 4}
>
No machines found matching "{searchQuery}"
</td>
</tr>
) : (
filteredAndSortedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
isAgent={loaderData.agent ? loaderData.agent === node.nodeKey : undefined}
isDisabled={
loaderData.writable
? false // If the user has write permissions, they can edit all machines
: node.user?.id !== loaderData.headscaleUserId
}
key={node.id}
magic={loaderData.magic}
node={node}
users={loaderData.users}
supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange}
/>
))
)}
</tbody>
</table>
</div>
</>
);
}
+39 -10
View File
@@ -1,21 +1,39 @@
import { data } from "react-router";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function authKeysAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.generate_authkeys);
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
if (!check) {
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
if (!canGenerateAny && !canGenerateOwn) {
throw data("You do not have permission to manage pre-auth keys", {
status: 403,
});
}
async function checkSelfServiceOwnership(userId: string) {
if (canGenerateAny || !canGenerateOwn) return;
const [targetUser] = await api.getUsers(userId);
if (!targetUser) {
throw data("User not found.", { status: 404 });
}
const targetSubject = getOidcSubject(targetUser);
if (principal.kind !== "oidc" || targetSubject !== principal.user.subject) {
throw data("You do not have permission to manage this user's pre-auth keys", {
status: 403,
});
}
}
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(session.api_key);
const action = formData.get("action_id")?.toString();
if (!action) {
throw data("Missing `action_id` in the form data.", {
@@ -25,13 +43,23 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
switch (action) {
case "add_preauthkey": {
const user = formData.get("user_id")?.toString();
if (!user) {
return data("Missing `user_id` in the form data.", {
const user = formData.get("user_id")?.toString() || null;
const aclTagsRaw = formData.get("acl_tags")?.toString() || "";
const aclTags = aclTagsRaw
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
if (!user && aclTags.length === 0) {
return data("Must specify either a user or ACL tags.", {
status: 400,
});
}
if (user) {
await checkSelfServiceOwnership(user);
}
const expiry = formData.get("expiry")?.toString();
if (!expiry) {
return data("Missing `expiry` in the form data.", {
@@ -53,17 +81,16 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
});
}
// Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(" ")[0]);
const date = new Date();
date.setDate(date.getDate() + day);
const key = await api.createPreAuthKey(
user,
ephemeral === "on",
reusable === "on",
date,
[], // TODO
aclTags.length > 0 ? aclTags : null,
);
return data({ success: true as const, key: key.key });
@@ -83,6 +110,8 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
});
}
await checkSelfServiceOwnership(user);
await api.expirePreAuthKey(user, key);
return data("Pre-auth key expired");
}
@@ -1,28 +1,31 @@
import type { PreAuthKey, User } from "~/types";
import Attribute from "~/components/Attribute";
import type { PreAuthKey, User } from "~/types";
import { getUserDisplayName } from "~/utils/user";
import ExpireAuthKey from "./dialogs/expire-auth-key";
interface Props {
authKey: PreAuthKey;
user: User;
user: User | null;
}
export default function AuthKeyRow({ authKey, user }: Props) {
const createdAt = new Date(authKey.createdAt).toLocaleString();
const expiration = new Date(authKey.expiration).toLocaleString();
const isExpired =
(authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date();
const userDisplay = user ? getUserDisplayName(user) : "(Tag Only)";
return (
<div className="w-full">
<Attribute name="Key" value={authKey.key} />
<Attribute name="User" value={user.name || user.displayName || user.email || user.id} />
<Attribute name="User" value={userDisplay} />
<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} />
{!((authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date()) && (
{!isExpired && user && (
<div className="mt-2" suppressHydrationWarning>
<ExpireAuthKey authKey={authKey} user={user} />
</div>
@@ -1,29 +1,49 @@
import { Key, useEffect, useRef, useState } from "react";
import { useFetcher } from "react-router";
import type { User } from "~/types";
import Button from "~/components/Button";
import Code from "~/components/Code";
import Dialog from "~/components/Dialog";
import Input from "~/components/Input";
import Link from "~/components/Link";
import NumberInput from "~/components/NumberInput";
import Select from "~/components/Select";
import Switch from "~/components/Switch";
import type { User } from "~/types";
import toast from "~/utils/toast";
import { getUserDisplayName } from "~/utils/user";
interface AddAuthKeyProps {
users: User[];
url: string;
selfServiceOnly: boolean;
currentSubject?: string;
}
export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
function findCurrentUser(users: User[], subject: string | undefined): User | undefined {
if (!subject) return undefined;
return users.find((u) => {
if (u.provider !== "oidc" || !u.providerId) return false;
return u.providerId.split("/").pop() === subject;
});
}
export default function AddAuthKey({
users,
url,
selfServiceOnly,
currentSubject,
}: AddAuthKeyProps) {
const fetcher = useFetcher();
const submittingRef = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [userId, setUserId] = useState<Key | null>(users[0]?.id);
const [tagOnly, setTagOnly] = useState(false);
const currentUser = selfServiceOnly ? findCurrentUser(users, currentSubject) : null;
const availableUsers = selfServiceOnly && currentUser ? [currentUser] : users;
const [userId, setUserId] = useState<Key | null>(availableUsers[0]?.id);
const [tags, setTags] = useState("");
const createdKey = fetcher.data?.success ? fetcher.data.key : null;
@@ -37,11 +57,21 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
if (!isOpen) {
setReusable(false);
setEphemeral(false);
setUserId(users[0]?.id);
setTagOnly(false);
setUserId(availableUsers[0]?.id);
setTags("");
fetcher.data = undefined;
}
}, [isOpen]);
const parsedTags = tags
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0)
.map((t) => (t.startsWith("tag:") ? t : `tag:${t}`));
const canSubmit = tagOnly ? parsedTags.length > 0 : userId != null;
return (
<Dialog
isOpen={isOpen}
@@ -84,30 +114,62 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
submittingRef.current = true;
const form = new FormData(event.currentTarget as HTMLFormElement);
form.set("action_id", "add_preauthkey");
form.set("user_id", userId?.toString() ?? "");
form.set("user_id", tagOnly ? "" : (userId?.toString() ?? ""));
form.set("reusable", reusable ? "on" : "off");
form.set("ephemeral", ephemeral ? "on" : "off");
form.set("acl_tags", parsedTags.join(","));
fetcher.submit(form, { method: "POST" });
}}
isDisabled={fetcher.state !== "idle"}
isDisabled={fetcher.state !== "idle" || !canSubmit}
>
<Dialog.Title>Generate auth key</Dialog.Title>
<Select
{!selfServiceOnly && (
<div className="mb-4 flex items-center justify-between gap-2">
<div>
<Dialog.Text className="font-semibold">Tag-only key</Dialog.Text>
<Dialog.Text className="text-sm">
Create a key owned by ACL tags instead of a user.
</Dialog.Text>
</div>
<Switch
defaultSelected={tagOnly}
label="Tag-only"
onChange={() => setTagOnly(!tagOnly)}
/>
</div>
)}
{!tagOnly && (
<Select
className="mb-2"
description={
selfServiceOnly
? "You can only create keys for your own user."
: "Machines will belong to this user when they authenticate."
}
isDisabled={selfServiceOnly}
isRequired
label="User"
onSelectionChange={(value) => setUserId(value)}
placeholder="Select a user"
selectedKey={userId}
>
{availableUsers.map((user) => (
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
))}
</Select>
)}
<Input
className="mb-2"
description="This is the user machines will belong to when they authenticate."
isRequired
label="User"
onSelectionChange={(value) => {
setUserId(value);
}}
placeholder="Select a user"
>
{users.map((user) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
description="Comma-separated tags (e.g. server, prod). The tag: prefix is added automatically."
isRequired={tagOnly}
label="ACL Tags"
onChange={(value) => setTags(value)}
placeholder="server, prod"
value={tags}
/>
<NumberInput
defaultValue={90}
description="Set this key to expire after a certain number of days."
@@ -132,9 +194,7 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
<Switch
defaultSelected={reusable}
label="Reusable"
onChange={() => {
setReusable(!reusable);
}}
onChange={() => setReusable(!reusable)}
/>
</div>
<div className="mt-6 flex items-center justify-between gap-2">
@@ -154,9 +214,7 @@ export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
<Switch
defaultSelected={ephemeral}
label="Ephemeral"
onChange={() => {
setEphemeral(!ephemeral);
}}
onChange={() => setEphemeral(!ephemeral)}
/>
</div>
</Dialog.Panel>
+100 -48
View File
@@ -8,61 +8,92 @@ import Notice from "~/components/Notice";
import Select from "~/components/Select";
import TableList from "~/components/TableList";
import { Capabilities } from "~/server/web/roles";
import type { PreAuthKey } from "~/types";
import type { User } from "~/types/User";
import log from "~/utils/log";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/overview";
import { authKeysAction } from "./actions";
import AuthKeyRow from "./auth-key-row";
import AddAuthKey from "./dialogs/add-auth-key";
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const api = context.hsApi.getRuntimeClient(session.api_key);
const principal = await context.auth.require(request);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const users = await api.getUsers();
const preAuthKeys = await Promise.all(
users
.filter((user) => user.name?.length > 0) // Filter out any invalid users
.map(async (user) => {
try {
const preAuthKeys = await api.getPreAuthKeys(user.id);
return {
success: true,
user,
preAuthKeys,
};
} catch (error) {
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
return {
success: false,
user,
error,
preAuthKeys: [],
};
}
}),
);
const keys = preAuthKeys
.filter(({ success }) => success)
.map(({ user, preAuthKeys }) => ({
user,
preAuthKeys,
}));
let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[];
let missing: { user: User; error: unknown }[] = [];
const missing = preAuthKeys
.filter(({ success }) => !success)
.map(({ user, error }) => ({
user,
error,
}));
// Try fetching all keys at once (Headscale 0.28+), fall back to per-user
let allKeys: PreAuthKey[] | null = null;
try {
allKeys = await api.getAllPreAuthKeys();
} catch {
// older versions don't support this endpoint
}
if (allKeys !== null) {
const keysByUser = new Map<string | null, PreAuthKey[]>();
for (const key of allKeys) {
const userId = key.user?.id ?? null;
const existing = keysByUser.get(userId) ?? [];
existing.push(key);
keysByUser.set(userId, existing);
}
keys = [];
const tagOnly = keysByUser.get(null);
if (tagOnly?.length) {
keys.push({ user: null, preAuthKeys: tagOnly });
}
for (const user of users) {
const userKeys = keysByUser.get(user.id);
if (userKeys?.length) {
keys.push({ user, preAuthKeys: userKeys });
}
}
} else {
type FetchResult =
| { success: true; user: User; preAuthKeys: PreAuthKey[] }
| { success: false; user: User; error: unknown; preAuthKeys: [] };
const results: FetchResult[] = await Promise.all(
users
.filter((u) => u.id?.length > 0)
.map(async (user) => {
try {
const preAuthKeys = await api.getPreAuthKeys(user.id);
return { success: true as const, user, preAuthKeys };
} catch (error) {
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
return { success: false as const, user, error, preAuthKeys: [] as const };
}
}),
);
keys = results
.filter(({ success }) => success)
.map(({ user, preAuthKeys }) => ({ user, preAuthKeys }));
missing = results
.filter((r): r is Extract<FetchResult, { success: false }> => !r.success)
.map(({ user, error }) => ({ user, error }));
}
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
return {
keys,
missing,
users,
access: await context.sessions.check(request, Capabilities.generate_authkeys),
access: canGenerateAny || canGenerateOwn,
selfServiceOnly: !canGenerateAny && canGenerateOwn,
currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
url: context.config.headscale.public_url ?? context.config.headscale.url,
};
}
@@ -71,7 +102,7 @@ export const action = authKeysAction;
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
export default function Page({
loaderData: { keys, missing, users, url, access },
loaderData: { keys, missing, users, url, access, selfServiceOnly, currentSubject },
}: Route.ComponentProps) {
const [selectedUser, setSelectedUser] = useState("__headplane_all");
const [status, setStatus] = useState<Status>("active");
@@ -85,7 +116,11 @@ export default function Page({
return true;
}
return user.id === selectedUser;
if (selectedUser === "__headplane_tag_only") {
return user === null;
}
return user?.id === selectedUser;
})
.flatMap(({ preAuthKeys }) => preAuthKeys)
.filter((key) => {
@@ -147,7 +182,7 @@ export default function Page({
An error occurred while fetching the authentication keys for the following users:{" "}
{missing.map(({ user }, index) => (
<>
<Code key={user.name}>{user.name}</Code>
<Code key={user.id}>{getUserDisplayName(user)}</Code>
{index < missing.length - 1 ? ", " : ". "}
</>
))}
@@ -165,7 +200,12 @@ export default function Page({
Tailscale documentation
</Link>
</p>
<AddAuthKey url={url} users={users} />
<AddAuthKey
currentSubject={currentSubject}
selfServiceOnly={selfServiceOnly}
url={url}
users={users}
/>
<div className="mt-4 flex items-center gap-4">
<Select
className="w-full"
@@ -177,11 +217,14 @@ export default function Page({
>
{[
<Select.Item key="__headplane_all">All</Select.Item>,
...keys.map(({ user }) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
)),
...keys
.filter((k): k is { user: User; preAuthKeys: PreAuthKey[] } => k.user !== null)
.map(({ user }) => (
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
)),
...(keys.some(({ user }) => user === null)
? [<Select.Item key="__headplane_tag_only">Tag Only</Select.Item>]
: []),
]}
</Select>
<Select
@@ -212,9 +255,18 @@ export default function Page({
</TableList.Item>
) : (
filteredKeys.map((key) => {
// Tag-only keys have no user
if (!key.user) {
return (
<TableList.Item key={key.id}>
<AuthKeyRow authKey={key} user={null} />
</TableList.Item>
);
}
// TODO: Why is Headscale using email as the user ID here?
// https://github.com/juanfont/headscale/issues/2520
const user = users.find((user) => user.id === key.user.id);
const user = users.find((user) => user.id === key.user?.id);
if (!user) {
return null;
}
+1 -1
View File
@@ -6,7 +6,7 @@ import Link from "~/components/Link";
import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) {
const oidcConnector = await context.oidcConnector?.get();
const oidcConnector = await context.oidc?.connector.get();
return {
config: context.hs.writable(),
isOidcEnabled: oidcConnector?.isValid ?? false,
+160 -169
View File
@@ -1,198 +1,189 @@
import { data } from 'react-router';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
import { data } from "react-router";
export async function restrictionAction({
request,
context,
}: Route.ActionArgs) {
const check = await context.sessions.check(
request,
Capabilities.configure_iam,
);
import { Capabilities } from "~/server/web/roles";
if (!check) {
throw data('You do not have permission to modify IAM settings.', {
status: 403,
});
}
import type { Route } from "./+types/overview";
if (!context.hs.writable()) {
throw data('The Headscale configuration file is not editable.', {
status: 403,
});
}
export async function restrictionAction({ request, context }: Route.ActionArgs) {
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.configure_iam);
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
throw data('No action provided.', {
status: 400,
});
}
if (!check) {
throw data("You do not have permission to modify IAM settings.", {
status: 403,
});
}
// We only need healthchecks which don't rely on an API key
const api = context.hsApi.getRuntimeClient('fake-api-key');
switch (action) {
case 'add_domain': {
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
if (!context.hs.writable()) {
throw data("The Headscale configuration file is not editable.", {
status: 403,
});
}
const domains = [
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
];
const formData = await request.formData();
const action = formData.get("action_id")?.toString();
if (!action) {
throw data("No action provided.", {
status: 400,
});
}
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
// We only need healthchecks which don't rely on an API key
const api = context.hsApi.getRuntimeClient("fake-api-key");
switch (action) {
case "add_domain": {
const domain = formData.get("domain")?.toString()?.trim();
if (!domain) {
throw data("No domain provided.", {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data('Domain added successfully.');
}
const domains = [...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain])];
case 'remove_domain': {
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
await context.hs.patch([
{
path: "oidc.allowed_domains",
value: domains,
},
]);
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
if (!storedDomains.includes(domain)) {
// Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data("Domain added successfully.");
}
// Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain);
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(api);
return data('Domain removed successfully.');
}
case "remove_domain": {
const domain = formData.get("domain")?.toString()?.trim();
if (!domain) {
throw data("No domain provided.", {
status: 400,
});
}
case 'add_group': {
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
if (!storedDomains.includes(domain)) {
// Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, {
status: 400,
});
}
const groups = [
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
];
// Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain);
await context.hs.patch([
{
path: "oidc.allowed_domains",
value: domains,
},
]);
context.integration?.onConfigChange(api);
return data("Domain removed successfully.");
}
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
case "add_group": {
const group = formData.get("group")?.toString()?.trim();
if (!group) {
throw data("No group provided.", {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data('Group added successfully.');
}
const groups = [...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group])];
case 'remove_group': {
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
await context.hs.patch([
{
path: "oidc.allowed_groups",
value: groups,
},
]);
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
if (!storedGroups.includes(group)) {
// Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data("Group added successfully.");
}
// Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group);
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
case "remove_group": {
const group = formData.get("group")?.toString()?.trim();
if (!group) {
throw data("No group provided.", {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data('Group removed successfully.');
}
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
if (!storedGroups.includes(group)) {
// Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
status: 400,
});
}
case 'add_user': {
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
// Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group);
await context.hs.patch([
{
path: "oidc.allowed_groups",
value: groups,
},
]);
const users = [
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
];
context.integration?.onConfigChange(api);
return data("Group removed successfully.");
}
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
case "add_user": {
const user = formData.get("user")?.toString()?.trim();
if (!user) {
throw data("No user provided.", {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data('User added successfully.');
}
const users = [...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user])];
case 'remove_user': {
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
await context.hs.patch([
{
path: "oidc.allowed_users",
value: users,
},
]);
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
if (!storedUsers.includes(user)) {
// User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data("User added successfully.");
}
// Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user);
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
case "remove_user": {
const user = formData.get("user")?.toString()?.trim();
if (!user) {
throw data("No user provided.", {
status: 400,
});
}
context.integration?.onConfigChange(api);
return data('User removed successfully.');
}
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
if (!storedUsers.includes(user)) {
// User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
status: 400,
});
}
default: {
throw data('Invalid action provided.', {
status: 400,
});
}
}
// Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user);
await context.hs.patch([
{
path: "oidc.allowed_users",
value: users,
},
]);
context.integration?.onConfigChange(api);
return data("User removed successfully.");
}
default: {
throw data("Invalid action provided.", {
status: 400,
});
}
}
}
+81 -98
View File
@@ -1,108 +1,91 @@
import { data, Link as RemixLink } from 'react-router';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
import { restrictionAction } from './actions';
import AddDomain from './dialogs/add-domain';
import AddGroup from './dialogs/add-group';
import AddUser from './dialogs/add-user';
import RestrictionTable from './table';
import { data, Link as RemixLink } from "react-router";
import Link from "~/components/Link";
import Notice from "~/components/Notice";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
import { restrictionAction } from "./actions";
import AddDomain from "./dialogs/add-domain";
import AddGroup from "./dialogs/add-group";
import AddUser from "./dialogs/add-user";
import RestrictionTable from "./table";
export async function loader({ request, context }: Route.LoaderArgs) {
const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) {
throw data('You do not have permission to view IAM settings.', {
status: 403,
});
}
const principal = await context.auth.require(request);
const check = context.auth.can(principal, Capabilities.read_users);
if (!check) {
throw data("You do not have permission to view IAM settings.", {
status: 403,
});
}
if (!context.hs.c?.oidc) {
throw data('OIDC is not configured on this Headscale instance.', {
status: 501,
});
}
if (!context.hs.c?.oidc) {
throw data("OIDC is not configured on this Headscale instance.", {
status: 501,
});
}
return {
access: await context.sessions.check(request, Capabilities.configure_iam),
writable: context.hs.writable(),
settings: {
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
users: [...new Set(context.hs.c.oidc.allowed_users)],
},
};
return {
access: context.auth.can(principal, Capabilities.configure_iam),
writable: context.hs.writable(),
settings: {
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
users: [...new Set(context.hs.c.oidc.allowed_users)],
},
};
}
export const action = restrictionAction;
export default function Page({
loaderData: { access, writable, settings },
}: Route.ComponentProps) {
const isDisabled = writable ? !access : true;
export default function Page({ loaderData: { access, writable, settings } }: Route.ComponentProps) {
const isDisabled = writable ? !access : true;
return (
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-full sm:w-2/3">
<p className="mb-4 text-md">
<RemixLink className="font-medium" to="/settings">
Settings
</RemixLink>
<span className="mx-2">/</span> Authentication Restrictions
</p>
{!access ? (
<Notice
title="Authentication permissions restricted"
variant="warning"
>
You do not have the necessary permissions to edit the Authentication
Restrictions settings. Please contact your administrator to request
access or to make changes to these settings.
</Notice>
) : !writable ? (
<Notice title="Configuration Locked" variant="error">
The Headscale configuration file is not editable through the web
interface. Please ensure that you have correctly given Headplane
write access to the file.
</Notice>
) : undefined}
<h1 className="text-2xl font-medium mb-2 mt-4">
Authentication Restrictions
</h1>
<p>
Headscale supports restricting OIDC authentication to only allow
certain email domains, groups, or users to authenticate. This can be
used to limit access to your Tailnet to only certain users or groups
and Headplane will also respect these settings when authenticating.{' '}
<Link
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
Learn More
</Link>
</p>
</div>
<RestrictionTable
isDisabled={isDisabled}
type="domain"
values={settings.domains}
>
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable
isDisabled={isDisabled}
type="group"
values={settings.groups}
>
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable
isDisabled={isDisabled}
type="user"
values={settings.users}
>
<AddUser isDisabled={isDisabled} users={settings.users} />
</RestrictionTable>
</div>
);
return (
<div className="flex max-w-(--breakpoint-lg) flex-col gap-4">
<div className="flex w-full flex-col sm:w-2/3">
<p className="text-md mb-4">
<RemixLink className="font-medium" to="/settings">
Settings
</RemixLink>
<span className="mx-2">/</span> Authentication Restrictions
</p>
{!access ? (
<Notice title="Authentication permissions restricted" variant="warning">
You do not have the necessary permissions to edit the Authentication Restrictions
settings. Please contact your administrator to request access or to make changes to
these settings.
</Notice>
) : !writable ? (
<Notice title="Configuration Locked" variant="error">
The Headscale configuration file is not editable through the web interface. Please
ensure that you have correctly given Headplane write access to the file.
</Notice>
) : undefined}
<h1 className="mt-4 mb-2 text-2xl font-medium">Authentication Restrictions</h1>
<p>
Headscale supports restricting OIDC authentication to only allow certain email domains,
groups, or users to authenticate. This can be used to limit access to your Tailnet to only
certain users or groups and Headplane will also respect these settings when
authenticating.{" "}
<Link
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
Learn More
</Link>
</p>
</div>
<RestrictionTable isDisabled={isDisabled} type="domain" values={settings.domains}>
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable isDisabled={isDisabled} type="group" values={settings.groups}>
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable isDisabled={isDisabled} type="user" values={settings.users}>
<AddUser isDisabled={isDisabled} users={settings.users} />
</RestrictionTable>
</div>
);
}
+8 -13
View File
@@ -6,10 +6,10 @@ import { data, type ShouldRevalidateFunction, useSubmit } from "react-router";
import { ExternalScriptsHandle } from "remix-utils/external-scripts";
import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema";
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
import { useLiveData } from "~/utils/live-data";
import type { Route } from "./+types/console";
import UserPrompt from "./user-prompt";
import XTerm from "./xterm.client";
@@ -35,28 +35,23 @@ export async function loader({ request, context }: Route.LoaderArgs) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
const session = await context.sessions.auth(request);
if (session.user.subject === "unknown-non-oauth") {
const principal = await context.auth.require(request);
if (principal.kind === "api_key") {
throw data("Only OAuth users are allowed to use WebSSH", 403);
}
const api = context.hsApi.getRuntimeClient(session.api_key);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const users = await api.getUsers();
// MARK: This assumes that a user has authenticated with Headscale first
// Since the only way to enforce permissions via ACLs is to generate a
// pre-authkey which REQUIRES a user ID, meaning the user has to have
// authenticated with Headscale first.
const lookup = users.find((u) => {
const subject = u.providerId?.split("/").pop();
if (!subject) {
return false;
}
return subject === session.user.subject;
});
const lookup = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
if (!lookup) {
throw data(`User with subject ${session.user.subject} not found within Headscale`, 404);
throw data(`User with subject ${principal.user.subject} not found within Headscale`, 404);
}
const preAuthKey = await api.createPreAuthKey(
@@ -157,7 +152,7 @@ function generateHostname(username: string) {
}
export async function action({ request, context }: Route.ActionArgs) {
await context.sessions.auth(request);
await context.auth.require(request);
if (!context.agents?.agentID()) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
+95 -67
View File
@@ -1,74 +1,102 @@
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';
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 LinkUser from "../dialogs/link-user";
import Reassign from "../dialogs/reassign-user";
import Rename from "../dialogs/rename-user";
interface MenuProps {
user: User & {
headplaneRole: string;
machines: Machine[];
};
user: User & {
headplaneRole: string;
machines: Machine[];
};
headscaleUsers: { id: string; name: string; claimed: boolean }[];
currentLink?: string;
}
type Modal = 'rename' | 'delete' | 'reassign' | null;
type Modal = "rename" | "delete" | "reassign" | "link" | null;
export default function UserMenu({ user }: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
return (
<>
{modal === 'rename' && (
<Rename
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === 'delete' && (
<Delete
isOpen={modal === 'delete'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === 'reassign' && (
<Reassign
isOpen={modal === 'reassign'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : ['reassign']}>
<Menu.IconButton
className={cn(
'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
)}
label="Machine Options"
>
<Ellipsis className="h-5" />
</Menu.IconButton>
<Menu.Panel 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>
</>
);
const disabledKeys: string[] = [];
if (user.provider === "oidc") {
disabledKeys.push("rename");
} else {
disabledKeys.push("reassign", "link");
}
// Filter linkable users: unclaimed, or the one currently linked to this user
const linkableUsers = headscaleUsers.filter((u) => !u.claimed || u.id === currentLink);
return (
<>
{modal === "rename" && (
<Rename
isOpen={modal === "rename"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === "delete" && (
<Delete
isOpen={modal === "delete"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === "reassign" && (
<Reassign
isOpen={modal === "reassign"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === "link" && (
<LinkUser
currentLink={currentLink}
headscaleUsers={linkableUsers}
isOpen={modal === "link"}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
<Menu disabledKeys={disabledKeys}>
<Menu.IconButton
className={cn(
"w-10 border-transparent bg-transparent py-0.5",
"border group-hover:border-headplane-200",
"dark:group-hover:border-headplane-700",
)}
label="User Options"
>
<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="link">Link Headscale user</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>
</>
);
}
+88 -88
View File
@@ -1,97 +1,97 @@
import { CircleUser } from 'lucide-react';
import StatusCircle from '~/components/StatusCircle';
import { Machine, User } from '~/types';
import cn from '~/utils/cn';
import MenuOptions from './menu';
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[] };
role: string;
user: User & { machines: Machine[] };
headscaleUsers: { id: string; name: string; claimed: boolean }[];
currentLink?: string;
}
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,
);
export default function UserRow({ user, role, headscaleUsers, currentLink }: 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
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
key={user.id}
>
<td className="pl-0.5 py-2">
<div className="flex items-center">
{user.profilePicUrl ? (
<img
alt={user.name || user.displayName}
className="w-10 h-10 rounded-full"
src={user.profilePicUrl}
/>
) : (
<CircleUser className="w-10 h-10" />
)}
<div className="ml-4">
<p className={cn('font-semibold leading-snug')}>
{user.name || user.displayName}
</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
className="text-sm text-headplane-600 dark:text-headplane-300"
suppressHydrationWarning
>
{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 className="w-4 h-4" isOnline={isOnline} />
<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>
);
return (
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={user.id}>
<td className="py-2 pl-0.5">
<div className="flex items-center">
{user.profilePicUrl ? (
<img
alt={user.name || user.displayName}
className="h-10 w-10 rounded-full"
src={user.profilePicUrl}
/>
) : (
<CircleUser className="h-10 w-10" />
)}
<div className="ml-4">
<p className={cn("font-semibold leading-snug")}>{user.name || user.displayName}</p>
<p className="text-sm opacity-50">{user.email}</p>
</div>
</div>
</td>
<td className="py-2 pl-0.5">
<p>{mapRoleToName(role)}</p>
</td>
<td className="py-2 pl-0.5">
<p className="text-headplane-600 dark:text-headplane-300 text-sm" suppressHydrationWarning>
{new Date(user.createdAt).toLocaleDateString()}
</p>
</td>
<td className="py-2 pl-0.5">
<span
className={cn(
"flex items-center gap-x-1 text-sm",
"text-headplane-600 dark:text-headplane-300",
)}
>
<StatusCircle className="h-4 w-4" isOnline={isOnline} />
<p suppressHydrationWarning>
{isOnline ? "Connected" : new Date(lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
currentLink={currentLink}
headscaleUsers={headscaleUsers}
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';
}
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 <p className="opacity-50">No Access</p>;
default:
return "Unknown";
}
}
+58
View File
@@ -0,0 +1,58 @@
import Dialog from "~/components/Dialog";
import Notice from "~/components/Notice";
import type { User } from "~/types";
import cn from "~/utils/cn";
interface LinkUserProps {
user: User & { headplaneRole: string };
headscaleUsers: { id: string; name: string }[];
currentLink?: string;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
export default function LinkUser({
user,
headscaleUsers,
currentLink,
isOpen,
setIsOpen,
}: LinkUserProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Link Headscale user for {user.name || user.displayName}</Dialog.Title>
<Dialog.Text className="mb-6">
Select which Headscale user this OIDC identity should be linked to. This controls which
machines they can manage and enables self-service features.
</Dialog.Text>
{headscaleUsers.length === 0 ? (
<Notice>All Headscale users are already linked to other accounts.</Notice>
) : (
<>
<input name="action_id" type="hidden" value="link_user" />
<input name="user_id" type="hidden" value={user.id} />
<select
className={cn(
"w-full rounded-lg border p-2",
"border-headplane-200 dark:border-headplane-700",
"bg-headplane-50 dark:bg-headplane-900",
)}
defaultValue={currentLink ?? ""}
name="headscale_user_id"
required
>
<option value="">Select a Headscale user...</option>
{headscaleUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
{u.id === currentLink ? " (current)" : ""}
</option>
))}
</select>
</>
)}
</Dialog.Panel>
</Dialog>
);
}
+48 -16
View File
@@ -1,20 +1,52 @@
import { eq } from 'drizzle-orm';
import { redirect } from 'react-router';
import { users } from '~/server/db/schema';
import type { Route } from './+types/onboarding-skip';
import { eq } from "drizzle-orm";
import { redirect } from "react-router";
import { users } from "~/server/db/schema";
import type { Route } from "./+types/onboarding-skip";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const { user } = await context.sessions.auth(request);
await context.db
.update(users)
.set({
onboarded: true,
})
.where(eq(users.sub, user.subject));
try {
const principal = await context.auth.require(request);
if (principal.kind !== "oidc") {
return redirect("/machines");
}
return redirect('/machines');
} catch {
return redirect('/login');
}
await context.db
.update(users)
.set({ onboarded: true })
.where(eq(users.sub, principal.user.subject));
return redirect("/machines");
} catch {
return redirect("/login");
}
}
export async function action({ request, context }: Route.ActionArgs) {
try {
const principal = await context.auth.require(request);
if (principal.kind !== "oidc") {
return redirect("/machines");
}
const formData = await request.formData();
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (headscaleUserId) {
const linked = await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
if (!linked) {
return redirect("/onboarding");
}
}
await context.db
.update(users)
.set({ onboarded: true })
.where(eq(users.sub, principal.user.subject));
return redirect("/machines");
} catch {
return redirect("/login");
}
}
+366 -307
View File
@@ -1,328 +1,387 @@
import { Icon } from '@iconify/react';
import { ArrowRight } from 'lucide-react';
import { useEffect } from 'react';
import { NavLink } 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 { Machine } from '~/types';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
import log from '~/utils/log';
import toast from '~/utils/toast';
import type { Route } from './+types/onboarding';
import { Icon } from "@iconify/react";
import { ArrowRight } from "lucide-react";
import { useEffect } from "react";
import { Form, NavLink } 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 { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
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";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/onboarding";
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const principal = await context.auth.require(request);
if (principal.kind !== "oidc") {
throw new Error("Onboarding is only available for OIDC users.");
}
// 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;
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 "iPhone":
case "iPad":
osValue = "ios";
break;
case 'Android':
osValue = 'android';
break;
case "Android":
osValue = "android";
break;
default:
osValue = 'linux';
break;
}
default:
osValue = "linux";
break;
}
const api = context.hsApi.getRuntimeClient(session.api_key);
let firstMachine: Machine | undefined;
try {
const nodes = await api.getNodes();
const node = nodes.find((n) => {
if (n.user.provider !== 'oidc') {
return false;
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
// 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 hsUserId = principal.user.headscaleUserId;
let firstMachine: Machine | undefined;
let needsUserLink = false;
let linkedUserName: string | undefined;
let headscaleUsers: { id: string; name: string }[] = [];
if (subject !== session.user.subject) {
return false;
}
try {
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
return true;
});
if (hsUserId) {
const hsUser = apiUsers.find((u) => u.id === hsUserId);
linkedUserName = hsUser ? getUserDisplayName(hsUser) : undefined;
firstMachine = nodes.find((n) => n.user?.id === hsUserId);
} else {
const matched = findHeadscaleUserBySubject(
apiUsers,
principal.user.subject,
principal.profile.email,
);
firstMachine = node;
} catch (e) {
// If we cannot lookup nodes, we cannot proceed
log.debug('api', 'Failed to lookup nodes %o', e);
}
if (matched) {
await context.auth.linkHeadscaleUser(principal.user.id, matched.id);
linkedUserName = getUserDisplayName(matched);
firstMachine = nodes.find((n) => n.user?.id === matched.id);
} else {
needsUserLink = true;
const claimed = await context.auth.claimedHeadscaleUserIds();
headscaleUsers = apiUsers
.filter((u) => !claimed.has(u.id))
.map((u) => ({
id: u.id,
name: getUserDisplayName(u),
}));
}
}
} catch (e) {
log.debug("api", "Failed to lookup nodes %o", e);
}
return {
user: session.user,
osValue,
firstMachine,
};
return {
user: {
subject: principal.user.subject,
name: principal.profile.name,
email: principal.profile.email,
username: principal.profile.username,
picture: principal.profile.picture,
},
osValue,
firstMachine,
needsUserLink,
linkedUserName,
headscaleUsers,
};
}
export default function Page({
loaderData: { user, osValue, firstMachine },
loaderData: { user, osValue, firstMachine, needsUserLink, linkedUserName, headscaleUsers },
}: Route.ComponentProps) {
const { pause, resume } = useLiveData();
useEffect(() => {
if (firstMachine) {
pause();
} else {
resume();
}
}, [firstMachine]);
const { pause, resume } = useLiveData();
useEffect(() => {
if (firstMachine) {
pause();
} else {
resume();
}
}, [firstMachine]);
const subject = user.email ? (
<>
as <strong>{user.email}</strong>
</>
) : (
'with your OIDC provider'
);
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 className="max-w-lg" variant="flat">
<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>
return (
<div className="fixed flex h-screen w-full items-center px-4">
<div className="mx-auto mb-24 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
{needsUserLink ? (
<Card className="col-span-2 mx-auto max-w-lg" variant="flat">
<Card.Title className="mb-4">Link your Headscale account</Card.Title>
<Card.Text className="mb-4">
Headplane couldn't automatically match your SSO identity to a Headscale user.
{headscaleUsers.length > 0
? " Select which Headscale user you are, or skip to continue without linking."
: " All Headscale users are already linked. You can skip this step and ask an admin to link your account later."}
</Card.Text>
{headscaleUsers.length > 0 ? (
<Form method="POST" action="/onboarding/skip">
<select
className={cn(
"mb-4 w-full rounded-lg border p-2",
"border-headplane-200 dark:border-headplane-700",
"bg-headplane-50 dark:bg-headplane-900",
)}
name="headscale_user_id"
required
>
<option value="">Select a user...</option>
{headscaleUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
<Button className="w-full" type="submit" variant="heavy">
Link and Continue
</Button>
</Form>
) : undefined}
<NavLink className="mt-3 block text-center" to="/onboarding/skip">
<Button className="w-full" variant="light">
Skip — I'll do this later
</Button>
</NavLink>
<p className="text-headplane-500 mt-2 text-center text-xs">
Without linking, you won't be able to see your own machines or generate pre-auth keys.
An admin can link your account later from the Users page.
</p>
</Card>
) : undefined}
{linkedUserName && !needsUserLink ? (
<Card className="col-span-2 mx-auto max-w-lg" variant="flat">
<p className="text-sm">
✓ Your account has been linked to Headscale user <strong>{linkedUserName}</strong>.
</p>
</Card>
) : undefined}
<Card className="max-w-lg" variant="flat">
<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
className="my-4"
defaultSelectedKey={osValue}
label="Download Selector"
>
<Options.Item
key="linux"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="ion:terminal" />
<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',
);
<Options className="my-4" defaultSelectedKey={osValue} label="Download Selector">
<Options.Item
key="linux"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="ion:terminal" />
<span>Linux</span>
</div>
}
>
<Button
className="text-md flex 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">
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
<span>Windows</span>
</div>
}
>
<a
aria-label="Download for Windows"
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
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">
<Icon
className="ml-1 w-4"
icon="streamline-logos:mac-finder-logo-solid"
/>
<span>macOS</span>
</div>
}
>
<a
aria-label="Download for macOS"
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
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">
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
<span>iOS</span>
</div>
}
>
<a
aria-label="Download for iOS"
href="https://apps.apple.com/us/app/tailscale/id1470499037"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
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">
<Icon className="ml-1 w-4" icon="material-symbols:android" />
<span>Android</span>
</div>
}
>
<a
aria-label="Download for Android"
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
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
className="size-6 mt-3"
isOnline={firstMachine.online}
/>
<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 className="text-xs font-mono opacity-50" key={ip}>
{ip}
</p>
))}
</div>
</div>
</div>
</div>
<NavLink to="/onboarding/skip">
<Button className="w-full" variant="heavy">
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 className="col-span-2 w-max mx-auto" to="/onboarding/skip">
<Button className="flex items-center gap-1">
I already know what I'm doing
<ArrowRight className="p-1" />
</Button>
</NavLink>
</div>
</div>
);
toast("Copied to clipboard");
}}
>
curl -fsSL https://tailscale.com/install.sh | sh
</Button>
<p className="text-headplane-600 dark:text-headplane-300 mt-1 text-center text-xs">
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">
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
<span>Windows</span>
</div>
}
>
<a
aria-label="Download for Windows"
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for Windows
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires Windows 10 or later.
</p>
</Options.Item>
<Options.Item
key="macos"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="streamline-logos:mac-finder-logo-solid" />
<span>macOS</span>
</div>
}
>
<a
aria-label="Download for macOS"
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for macOS
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
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">
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
<span>iOS</span>
</div>
}
>
<a
aria-label="Download for iOS"
href="https://apps.apple.com/us/app/tailscale/id1470499037"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for iOS
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires iOS 15 or later.
</p>
</Options.Item>
<Options.Item
key="android"
title={
<div className="flex items-center gap-1">
<Icon className="ml-1 w-4" icon="material-symbols:android" />
<span>Android</span>
</div>
}
>
<a
aria-label="Download for Android"
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
rel="noreferrer"
target="_blank"
>
<Button className="my-4 w-full" variant="heavy">
Download for Android
</Button>
</a>
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
Requires Android 8 or later.
</p>
</Options.Item>
</Options>
</Card>
<Card variant="flat">
{firstMachine ? (
<div className="flex h-full flex-col justify-between">
<Card.Title className="mb-8">
Success!
<br />
We found your first device
</Card.Title>
<div className="border-headplane-100 dark:border-headplane-800 rounded-xl border p-4">
<div className="flex items-start gap-4">
<StatusCircle className="mt-3 size-6" isOnline={firstMachine.online} />
<div>
<p className="leading-snug font-semibold">{firstMachine.givenName}</p>
<p className="font-mono text-sm opacity-50">{firstMachine.name}</p>
<div className="mt-6">
<p className="text-sm font-semibold">IP Addresses</p>
{firstMachine.ipAddresses.map((ip) => (
<p className="font-mono text-xs opacity-50" key={ip}>
{ip}
</p>
))}
</div>
</div>
</div>
</div>
<NavLink to="/onboarding/skip">
<Button className="w-full" variant="heavy">
Continue
</Button>
</NavLink>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center gap-4">
<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 className="col-span-2 mx-auto w-max" to="/onboarding/skip">
<Button className="flex items-center gap-1">
I already know what I'm doing
<ArrowRight className="p-1" />
</Button>
</NavLink>
</div>
</div>
);
}
+144 -124
View File
@@ -1,147 +1,167 @@
import { createHash } from 'node:crypto';
import { useEffect, useState } from 'react';
import { Capabilities } from '~/server/web/roles';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import type { Route } from './+types/overview';
import ManageBanner from './components/manage-banner';
import UserRow from './components/user-row';
import { userAction } from './user-actions';
import { createHash } from "node:crypto";
import { eq } from "drizzle-orm";
import { useEffect, useState } from "react";
import { users as usersTable } from "~/server/db/schema";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { Machine, User } from "~/types";
import cn from "~/utils/cn";
import { getUserDisplayName } from "~/utils/user";
import type { Route } from "./+types/overview";
import ManageBanner from "./components/manage-banner";
import UserRow from "./components/user-row";
import { userAction } from "./user-actions";
interface UserMachine extends User {
machines: Machine[];
machines: Machine[];
}
export async function loader({ request, context }: Route.LoaderArgs) {
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 principal = await context.auth.require(request);
const check = await context.auth.can(principal, 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 writablePermission = await context.auth.can(principal, Capabilities.write_users);
const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
const users = apiUsers.map((user) => ({
...user,
machines: nodes.filter((node) => node.user.id === user.id),
profilePicUrl:
context.config.oidc?.profile_picture_source === 'gravatar'
? (() => {
if (!user.email) {
return undefined;
}
const users = apiUsers.map((user) => ({
...user,
machines: nodes.filter((node) => node.user?.id === user.id),
profilePicUrl:
context.config.oidc?.profile_picture_source === "gravatar"
? (() => {
if (!user.email) {
return undefined;
}
const emailHash = user.email.trim().toLowerCase();
const hash = createHash('sha256').update(emailHash).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
})()
: user.profilePicUrl,
}));
const emailHash = user.email.trim().toLowerCase();
const hash = createHash("sha256").update(emailHash).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
})()
: user.profilePicUrl,
}));
const roles = await Promise.all(
users
.sort((a, b) => a.name.localeCompare(b.name))
.map(async (user) => {
if (user.provider !== 'oidc') {
return 'no-oidc';
}
const roles = await Promise.all(
users
.sort((a, b) => a.name.localeCompare(b.name))
.map(async (user) => {
if (user.provider !== "oidc") {
return "no-oidc";
}
if (user.provider === 'oidc' && user.providerId) {
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = user.providerId.split('/').pop();
if (!subject) {
return 'invalid-oidc';
}
const subject = getOidcSubject(user);
if (!subject) {
return "invalid-oidc";
}
const role = await context.sessions.roleForSubject(subject);
return role ?? 'no-role';
}
const role = await context.auth.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;
}
}
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
// Build linkable Headscale users for admin link dialog
const claimed = await context.auth.claimedHeadscaleUserIds();
const headscaleUsers = apiUsers.map((u) => ({
id: u.id,
name: getUserDisplayName(u),
claimed: claimed.has(u.id),
}));
return {
writable: writablePermission, // whether the user can write to the API
oidc: context.config.oidc
? {
issuer: context.config.oidc.issuer,
}
: undefined,
roles,
magic,
users,
};
// Build a map of Headscale user -> linked Headplane subject
const userLinks: Record<string, string | undefined> = {};
for (const u of apiUsers) {
const subject = getOidcSubject(u);
if (subject) {
const [hp] = await context.db
.select({ hsId: usersTable.headscale_user_id })
.from(usersTable)
.where(eq(usersTable.sub, subject))
.limit(1);
userLinks[u.id] = hp?.hsId ?? undefined;
}
}
return {
writable: writablePermission, // whether the user can write to the API
oidc: context.config.oidc
? {
issuer: context.config.oidc.issuer,
}
: undefined,
roles,
magic,
users,
headscaleUsers,
userLinks,
};
}
export const action = userAction;
export default function Page({ loaderData }: Route.ComponentProps) {
const [users, setUsers] = useState<UserMachine[]>(loaderData.users);
const [users, setUsers] = useState<UserMachine[]>(loaderData.users);
// This useEffect is entirely for the purpose of updating the users when the
// drag and drop changes the machines between users. It's pretty hacky, but
// the idea is to treat data.users as the source of truth and update the
// local state when it changes.
useEffect(() => {
setUsers(loaderData.users);
}, [loaderData.users]);
// This useEffect is entirely for the purpose of updating the users when the
// drag and drop changes the machines between users. It's pretty hacky, but
// the idea is to treat data.users as the source of truth and update the
// local state when it changes.
useEffect(() => {
setUsers(loaderData.users);
}, [loaderData.users]);
return (
<>
<h1 className="text-2xl font-medium mb-1.5">Users</h1>
<p className="mb-8 text-md">
Manage the users in your network and their permissions.
</p>
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
<div className="overflow-x-auto">
<table className="table-auto w-full rounded-lg min-w-[640px]">
<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}
role={loaderData.roles[users.indexOf(user)]}
user={user}
/>
))}
</tbody>
</table>
</div>
</>
);
return (
<>
<h1 className="mb-1.5 text-2xl font-medium">Users</h1>
<p className="text-md mb-8">Manage the users in your network and their permissions.</p>
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
<div className="overflow-x-auto">
<table className="w-full min-w-[640px] table-auto rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="px-0.5 text-left">
<th className="pb-2 text-xs font-bold uppercase">User</th>
<th className="pb-2 text-xs font-bold uppercase">Role</th>
<th className="pb-2 text-xs font-bold uppercase">Created At</th>
<th className="pb-2 text-xs font-bold uppercase">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}
currentLink={loaderData.userLinks[user.id]}
headscaleUsers={loaderData.headscaleUsers}
role={loaderData.roles[users.indexOf(user)]}
user={user}
/>
))}
</tbody>
</table>
</div>
</>
);
}
+123 -99
View File
@@ -1,115 +1,139 @@
import { data } from 'react-router';
import { Capabilities, Roles } from '~/server/web/roles';
import type { Route } from './+types/overview';
import { data } from "react-router";
import { getOidcSubject } from "~/server/web/headscale-identity";
import { Capabilities } from "~/server/web/roles";
import type { Role } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function userAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.write_users);
if (!check) {
throw data('You do not have permission to update users', {
status: 403,
});
}
const principal = await context.auth.require(request);
const check = await context.auth.can(principal, Capabilities.write_users);
if (!check) {
throw data("You do not have permission to update users", {
status: 403,
});
}
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
throw data('Missing `action_id` in the form data.', {
status: 404,
});
}
const formData = await request.formData();
const action = formData.get("action_id")?.toString();
if (!action) {
throw data("Missing `action_id` in the form data.", {
status: 404,
});
}
const api = context.hsApi.getRuntimeClient(session.api_key);
switch (action) {
case 'create_user': {
const name = formData.get('username')?.toString();
const displayName = formData.get('display_name')?.toString();
const email = formData.get('email')?.toString();
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
switch (action) {
case "create_user": {
const name = formData.get("username")?.toString();
const displayName = formData.get("display_name")?.toString();
const email = formData.get("email")?.toString();
if (!name) {
throw data('Missing `username` in the form data.', {
status: 400,
});
}
if (!name) {
throw data("Missing `username` in the form data.", {
status: 400,
});
}
await api.createUser(name, email, displayName);
return { message: 'User created successfully' };
}
case 'delete_user': {
const userId = formData.get('user_id')?.toString();
if (!userId) {
throw data('Missing `user_id` in the form data.', {
status: 400,
});
}
await api.createUser(name, email, displayName);
return { message: "User created successfully" };
}
case "delete_user": {
const userId = formData.get("user_id")?.toString();
if (!userId) {
throw data("Missing `user_id` in the form data.", {
status: 400,
});
}
await api.deleteUser(userId);
return { message: 'User deleted successfully' };
}
case 'rename_user': {
const userId = formData.get('user_id')?.toString();
const newName = formData.get('new_name')?.toString();
if (!userId || !newName) {
return data({ success: false }, 400);
}
await api.deleteUser(userId);
return { message: "User deleted successfully" };
}
case "rename_user": {
const userId = formData.get("user_id")?.toString();
const newName = formData.get("new_name")?.toString();
if (!userId || !newName) {
return data({ success: false }, 400);
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data(`No user found with id: ${userId}`, { status: 400 });
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data(`No user found with id: ${userId}`, { status: 400 });
}
if (user.provider === 'oidc') {
// OIDC users cannot be renamed via this endpoint, return an error
throw data('Users managed by OIDC cannot be renamed', {
status: 403,
});
}
if (user.provider === "oidc") {
// OIDC users cannot be renamed via this endpoint, return an error
throw data("Users managed by OIDC cannot be renamed", {
status: 403,
});
}
await api.renameUser(userId, newName);
return { message: 'User renamed successfully' };
}
case 'reassign_user': {
const userId = formData.get('user_id')?.toString();
const newRole = formData.get('new_role')?.toString();
if (!userId || !newRole) {
throw data('Missing `user_id` or `new_role` in the form data.', {
status: 400,
});
}
await api.renameUser(userId, newName);
return { message: "User renamed successfully" };
}
case "reassign_user": {
const userId = formData.get("user_id")?.toString();
const newRole = formData.get("new_role")?.toString();
if (!userId || !newRole) {
throw data("Missing `user_id` or `new_role` in the form data.", {
status: 400,
});
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user?.providerId) {
throw data('Specified user is not an OIDC user', {
status: 400,
});
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", {
status: 400,
});
}
// 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 data(
'Malformed `providerId` for the specified user. Cannot find subject.',
{ status: 400 },
);
}
const subject = getOidcSubject(user);
if (!subject) {
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
}
const result = await context.sessions.reassignSubject(
subject,
newRole as keyof typeof Roles,
);
const result = await context.auth.reassignSubject(subject, newRole as Role);
if (!result) {
throw data('Failed to reassign user role.', { status: 500 });
}
if (!result) {
throw data("Failed to reassign user role.", { status: 500 });
}
return { message: 'User reassigned successfully' };
}
default:
throw data('Invalid `action_id` provided.', {
status: 400,
});
}
return { message: "User reassigned successfully" };
}
case "link_user": {
const userId = formData.get("user_id")?.toString();
const headscaleUserId = formData.get("headscale_user_id")?.toString();
if (!userId || !headscaleUserId) {
throw data("Missing `user_id` or `headscale_user_id` in the form data.", {
status: 400,
});
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data("Specified user not found", { status: 400 });
}
const subject = getOidcSubject(user);
if (!subject) {
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
}
const linked = await context.auth.linkHeadscaleUserBySubject(subject, headscaleUserId);
if (!linked) {
throw data("That Headscale user is already linked to another account.", { status: 409 });
}
return { message: "Headscale user linked successfully" };
}
default:
throw data("Invalid `action_id` provided.", {
status: 400,
});
}
}
+138 -149
View File
@@ -1,206 +1,195 @@
import { type } from 'arktype';
import log from '~/utils/log';
import DockerIntegration from './integration/docker';
import KubernetesIntegration from './integration/kubernetes';
import ProcIntegration from './integration/proc';
import { deprecatedField } from './utils';
import { type } from "arktype";
import log from "~/utils/log";
import DockerIntegration from "./integration/docker";
import KubernetesIntegration from "./integration/kubernetes";
import ProcIntegration from "./integration/proc";
import { deprecatedField } from "./utils";
export const pathSupportedKeys = [
'server.cookie_secret',
'oidc.client_secret',
'oidc.headscale_api_key',
'integration.agent.pre_authkey',
"server.cookie_secret",
"oidc.client_secret",
"oidc.headscale_api_key",
"integration.agent.pre_authkey",
] as const;
const serverConfig = type({
host: 'string.ip = "0.0.0.0"',
port: 'number.integer = 3000',
base_url: 'string.url?',
data_path: 'string.lower = "/var/lib/headplane/"',
info_secret: 'string?',
host: 'string.ip = "0.0.0.0"',
port: "number.integer = 3000",
base_url: "string.url?",
data_path: 'string.lower = "/var/lib/headplane/"',
info_secret: "string?",
cookie_secret: '(32 <= string <= 32)',
cookie_secure: 'boolean = true',
cookie_domain: 'string.lower?',
cookie_max_age: 'number.integer = 86400',
cookie_secret: "(32 <= string <= 32)",
cookie_secure: "boolean = true",
cookie_domain: "string.lower?",
cookie_max_age: "number.integer = 86400",
});
const partialServerConfig = type({
host: 'string.ip?',
port: 'number.integer?',
base_url: 'string.url?',
data_path: 'string.lower?',
info_secret: 'string?',
host: "string.ip?",
port: "number.integer?",
base_url: "string.url?",
data_path: "string.lower?",
info_secret: "string?",
cookie_secret: '(32 <= string <= 32)?',
cookie_secure: 'boolean?',
cookie_domain: 'string.lower?',
cookie_max_age: 'number.integer?',
cookie_secret: "(32 <= string <= 32)?",
cookie_secure: "boolean?",
cookie_domain: "string.lower?",
cookie_max_age: "number.integer?",
});
const headscaleConfig = type({
url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)),
public_url: type('string.url')
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
.optional(),
config_path: 'string.lower?',
config_strict: 'boolean = true',
dns_records_path: 'string.lower?',
tls_cert_path: 'string.lower?',
url: type("string.url").pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v)),
public_url: type("string.url")
.pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v))
.optional(),
config_path: "string.lower?",
config_strict: "boolean = true",
dns_records_path: "string.lower?",
tls_cert_path: "string.lower?",
});
const partialHeadscaleConfig = type({
url: type('string.url')
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
.optional(),
public_url: type('string.url')
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
.optional(),
config_path: 'string.lower?',
config_strict: 'boolean?',
dns_records_path: 'string.lower?',
tls_cert_path: 'string.lower?',
url: type("string.url")
.pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v))
.optional(),
public_url: type("string.url")
.pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v))
.optional(),
config_path: "string.lower?",
config_strict: "boolean?",
dns_records_path: "string.lower?",
tls_cert_path: "string.lower?",
});
const oidcConfig = type({
issuer: 'string.url',
client_id: 'string',
client_secret: 'string',
headscale_api_key: 'string',
use_pkce: 'boolean = false',
redirect_uri: type('string.url')
.pipe((value, ctx) => {
log.warn(
'config',
'%s is deprecated and will be removed in 0.7.0',
ctx.propString,
);
enabled: "boolean = true",
issuer: "string.url",
client_id: "string",
client_secret: "string",
headscale_api_key: "string",
use_pkce: "boolean = false",
redirect_uri: type("string.url")
.pipe((value, ctx) => {
log.warn("config", "%s is deprecated and will be removed in 0.7.0", ctx.propString);
const cleanedValue = new URL(value.trim());
if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) {
cleanedValue.pathname = cleanedValue.pathname.replace(
`${__PREFIX__}/oidc/callback`,
'/',
);
const cleanedValue = new URL(value.trim());
if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) {
cleanedValue.pathname = cleanedValue.pathname.replace(`${__PREFIX__}/oidc/callback`, "/");
log.warn(
'config',
'Please migrate to using `server.base_url` with a value of "%s"',
cleanedValue.toString(),
);
}
log.warn(
"config",
'Please migrate to using `server.base_url` with a value of "%s"',
cleanedValue.toString(),
);
}
return cleanedValue.toString();
})
.optional(),
disable_api_key_login: 'boolean = false',
scope: 'string = "openid email profile"',
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
extra_params: 'Record<string, string>?',
return cleanedValue.toString();
})
.optional(),
disable_api_key_login: "boolean = false",
scope: 'string = "openid email profile"',
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
extra_params: "Record<string, string>?",
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
authorization_endpoint: "string.url?",
token_endpoint: "string.url?",
userinfo_endpoint: "string.url?",
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// Old/deprecated options
user_storage_file: 'string.lower = "/var/lib/headplane/users.json"',
strict_validation: type('unknown').narrow(deprecatedField()).optional(),
// Old/deprecated options
strict_validation: type("unknown").narrow(deprecatedField()).optional(),
});
const partialOidcConfig = type({
issuer: 'string.url?',
client_id: 'string?',
client_secret: 'string?',
use_pkce: 'boolean?',
headscale_api_key: 'string?',
redirect_uri: 'string.url?',
disable_api_key_login: 'boolean?',
scope: 'string?',
extra_params: 'Record<string, string>?',
profile_picture_source: '"oidc" | "gravatar"?',
enabled: "boolean?",
issuer: "string.url?",
client_id: "string?",
client_secret: "string?",
use_pkce: "boolean?",
headscale_api_key: "string?",
redirect_uri: "string.url?",
disable_api_key_login: "boolean?",
scope: "string?",
extra_params: "Record<string, string>?",
profile_picture_source: '"oidc" | "gravatar"?',
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
authorization_endpoint: "string.url?",
token_endpoint: "string.url?",
userinfo_endpoint: "string.url?",
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// Old/deprecated options
user_storage_file: 'string.lower?',
strict_validation: type('unknown').narrow(deprecatedField()).optional(),
// Old/deprecated options
strict_validation: type("unknown").narrow(deprecatedField()).optional(),
});
const agentConfig = type({
enabled: 'boolean',
host_name: 'string = "headplane-agent"',
pre_authkey: 'string',
cache_ttl: 'number.integer = 180000',
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
executable_path: 'string = "/usr/libexec/headplane/agent"',
work_dir: 'string = "/var/lib/headplane/agent"',
enabled: "boolean",
host_name: 'string = "headplane-agent"',
pre_authkey: "string",
cache_ttl: "number.integer = 180000",
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
executable_path: 'string = "/usr/libexec/headplane/agent"',
work_dir: 'string = "/var/lib/headplane/agent"',
});
const partialAgentConfig = type({
enabled: 'boolean?',
host_name: 'string?',
pre_authkey: 'string?',
cache_ttl: 'number.integer?',
cache_path: 'string?',
executable_path: 'string?',
work_dir: 'string?',
enabled: "boolean?",
host_name: "string?",
pre_authkey: "string?",
cache_ttl: "number.integer?",
cache_path: "string?",
executable_path: "string?",
work_dir: "string?",
});
const integrationConfig = type({
docker: DockerIntegration.configSchema.full,
kubernetes: KubernetesIntegration.configSchema.full,
proc: ProcIntegration.configSchema.full,
agent: agentConfig.optional(),
docker: DockerIntegration.configSchema.full,
kubernetes: KubernetesIntegration.configSchema.full,
proc: ProcIntegration.configSchema.full,
agent: agentConfig.optional(),
}).partial();
export const partialIntegrationConfig = type({
docker: DockerIntegration.configSchema.partial,
kubernetes: KubernetesIntegration.configSchema.partial,
proc: ProcIntegration.configSchema.partial,
agent: partialAgentConfig.optional(),
docker: DockerIntegration.configSchema.partial,
kubernetes: KubernetesIntegration.configSchema.partial,
proc: ProcIntegration.configSchema.partial,
agent: partialAgentConfig.optional(),
}).partial();
export const headplaneConfig = type({
debug: 'boolean = false',
server: serverConfig,
headscale: headscaleConfig,
oidc: oidcConfig.optional(),
integration: integrationConfig.optional(),
}).onDeepUndeclaredKey('delete');
debug: "boolean = false",
server: serverConfig,
headscale: headscaleConfig,
oidc: oidcConfig.optional(),
integration: integrationConfig.optional(),
}).onDeepUndeclaredKey("delete");
export const partialHeadplaneConfig = type({
debug: 'boolean?',
server: partialServerConfig.optional(),
headscale: partialHeadscaleConfig.optional(),
oidc: partialOidcConfig.optional(),
integration: partialIntegrationConfig.optional(),
debug: "boolean?",
server: partialServerConfig.optional(),
headscale: partialHeadscaleConfig.optional(),
oidc: partialOidcConfig.optional(),
integration: partialIntegrationConfig.optional(),
});
export type HeadplaneConfig = typeof headplaneConfig.infer;
export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer;
type DotNotationToObjects<
T extends string,
V,
> = T extends `${infer K}.${infer Rest}`
? { [P in K]?: DotNotationToObjects<Rest, V> }
: { [P in `${T}_path`]?: V };
type DotNotationToObjects<T extends string, V> = T extends `${infer K}.${infer Rest}`
? { [P in K]?: DotNotationToObjects<Rest, V> }
: { [P in `${T}_path`]?: V };
type ObjectDeepMerge<T> = T extends object
? {
[K in keyof T]: T[K] extends object ? ObjectDeepMerge<T[K]> : T[K];
}
: T;
? {
[K in keyof T]: T[K] extends object ? ObjectDeepMerge<T[K]> : T[K];
}
: T;
type ConfigWithPathKeys = ObjectDeepMerge<
DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined>
DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined>
>;
export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig &
ConfigWithPathKeys;
export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig & ConfigWithPathKeys;
+39 -41
View File
@@ -1,50 +1,48 @@
import { eq, isNotNull } from 'drizzle-orm';
import log from '~/utils/log';
import type { Route } from '../../layouts/+types/dashboard';
import { ephemeralNodes } from './schema';
import { eq, isNotNull } from "drizzle-orm";
export async function pruneEphemeralNodes({
context,
request,
}: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const ephemerals = await context.db
.select()
.from(ephemeralNodes)
.where(isNotNull(ephemeralNodes.node_key));
import log from "~/utils/log";
if (ephemerals.length === 0) {
log.debug('api', 'No ephemeral nodes to prune');
return;
}
import type { Route } from "../../layouts/+types/dashboard";
import { ephemeralNodes } from "./schema";
const api = context.hsApi.getRuntimeClient(session.api_key);
const nodes = await api.getNodes();
const toPrune = nodes.filter((node) => {
if (node.online) {
return false;
}
export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs) {
const principal = await context.auth.require(request);
const ephemerals = await context.db
.select()
.from(ephemeralNodes)
.where(isNotNull(ephemeralNodes.node_key));
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
});
if (ephemerals.length === 0) {
log.debug("api", "No ephemeral nodes to prune");
return;
}
if (toPrune.length === 0) {
log.debug('api', 'No SSH nodes to prune');
return;
}
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
const api = context.hsApi.getRuntimeClient(apiKey);
const nodes = await api.getNodes();
const toPrune = nodes.filter((node) => {
if (node.online) {
return false;
}
// Delete from the Headscale nodes list and then from the database
const promises = toPrune.map((node) => {
return async () => {
log.debug('api', `Pruning node ${node.name}`);
await api.deleteNode(node.id);
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
});
await context.db
.delete(ephemeralNodes)
.where(eq(ephemeralNodes.node_key, node.nodeKey));
log.debug('api', `Node ${node.name} pruned successfully`);
};
});
if (toPrune.length === 0) {
log.debug("api", "No SSH nodes to prune");
return;
}
await Promise.all(promises.map((p) => p()));
// Delete from the Headscale nodes list and then from the database
const promises = toPrune.map((node) => {
return async () => {
log.debug("api", `Pruning node ${node.name}`);
await api.deleteNode(node.id);
await context.db.delete(ephemeralNodes).where(eq(ephemeralNodes.node_key, node.nodeKey));
log.debug("api", `Node ${node.name} pruned successfully`);
};
});
await Promise.all(promises.map((p) => p()));
}
+37 -18
View File
@@ -1,31 +1,50 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { HostInfo } from '~/types';
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const ephemeralNodes = sqliteTable('ephemeral_nodes', {
auth_key: text('auth_key').primaryKey(),
node_key: text('node_key'),
import { HostInfo } from "~/types";
export const ephemeralNodes = sqliteTable("ephemeral_nodes", {
auth_key: text("auth_key").primaryKey(),
node_key: text("node_key"),
});
export type EphemeralNode = typeof ephemeralNodes.$inferSelect;
export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert;
export const hostInfo = sqliteTable('host_info', {
host_id: text('host_id').primaryKey(),
payload: text('payload', { mode: 'json' }).$type<HostInfo>(),
updated_at: integer('updated_at', { mode: 'timestamp' }).$default(
() => new Date(),
),
export const hostInfo = sqliteTable("host_info", {
host_id: text("host_id").primaryKey(),
payload: text("payload", { mode: "json" }).$type<HostInfo>(),
updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()),
});
export type HostInfoRecord = typeof hostInfo.$inferSelect;
export type HostInfoInsert = typeof hostInfo.$inferInsert;
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
sub: text('sub').notNull().unique(),
caps: integer('caps').notNull().default(0),
onboarded: integer('onboarded', { mode: 'boolean' }).notNull().default(false),
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
sub: text("sub").notNull().unique(),
role: text("role").notNull().default("member"),
headscale_user_id: text("headscale_user_id").unique(),
onboarded: integer("onboarded", { mode: "boolean" }).notNull().default(false),
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()),
last_login_at: integer("last_login_at", { mode: "timestamp" }),
// Deprecated: kept for migration compatibility, will be removed in 1.0
caps: integer("caps").notNull().default(0),
});
export type User = typeof users.$inferSelect;
export type UserInsert = typeof users.$inferInsert;
export type HeadplaneUser = typeof users.$inferSelect;
export type HeadplaneUserInsert = typeof users.$inferInsert;
export const authSessions = sqliteTable("auth_sessions", {
id: text("id").primaryKey(),
kind: text("kind").notNull(), // 'oidc' | 'api_key'
user_id: text("user_id"),
api_key_hash: text("api_key_hash"),
api_key_display: text("api_key_display"),
expires_at: integer("expires_at", { mode: "timestamp" }).notNull(),
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
});
export type AuthSessionRecord = typeof authSessions.$inferSelect;
export type AuthSessionInsert = typeof authSessions.$inferInsert;
@@ -1,69 +1,85 @@
import type { PreAuthKey } from '~/types';
import { defineApiEndpoints } from '../factory';
import type { PreAuthKey } from "~/types";
import { defineApiEndpoints } from "../factory";
export interface PreAuthKeyEndpoints {
/**
* Retrieves all pre-authentication keys for a specific user.
*
* @param user The user to retrieve pre-authentication keys for.
* @returns An array of `PreAuthKey` objects representing the pre-authentication keys.
*/
getPreAuthKeys(user: string): Promise<PreAuthKey[]>;
/**
* List all pre-auth keys. Requires Headscale 0.28+.
*/
getAllPreAuthKeys(): Promise<PreAuthKey[]>;
/**
* Creates a new pre-authentication key for a specific user.
*
* @param user The user to create the pre-authentication key for.
* @param ephemeral Whether the key is ephemeral.
* @param reusable Whether the key is reusable.
* @param expiration The expiration date of the key, or `null` for no expiration.
* @param aclTags An array of ACL tags to associate with the key, or `null` for none.
* @returns A `PreAuthKey` object representing the newly created pre-authentication key.
*/
createPreAuthKey(
user: string,
ephemeral: boolean,
reusable: boolean,
expiration: Date | null,
aclTags: string[] | null,
): Promise<PreAuthKey>;
/**
* Retrieves all pre-authentication keys for a specific user.
*
* @param user The user to retrieve pre-authentication keys for.
* @returns An array of `PreAuthKey` objects representing the pre-authentication keys.
*/
getPreAuthKeys(user: string): Promise<PreAuthKey[]>;
/**
* Expires a specific pre-authentication key for a user.
*
* @param user The user associated with the pre-authentication key.
* @param key The pre-authentication key to expire.
*/
expirePreAuthKey(user: string, key: string): Promise<void>;
/**
* Creates a new pre-authentication key.
* User can be null for tag-only keys (requires Headscale 0.28+).
*/
createPreAuthKey(
user: string | null,
ephemeral: boolean,
reusable: boolean,
expiration: Date | null,
aclTags: string[] | null,
): Promise<PreAuthKey>;
/**
* Expires a specific pre-authentication key for a user.
*
* @param user The user associated with the pre-authentication key.
* @param key The pre-authentication key to expire.
*/
expirePreAuthKey(user: string, key: string): Promise<void>;
}
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
getPreAuthKeys: async (user) => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>('GET', 'v1/preauthkey', apiKey, { user });
getAllPreAuthKeys: async () => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>("GET", "v1/preauthkey", apiKey, {});
return preAuthKeys;
},
return preAuthKeys;
},
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
const { preAuthKey } = await client.apiFetch<{
preAuthKey: PreAuthKey;
}>('POST', 'v1/preauthkey', apiKey, {
user,
ephemeral,
reusable,
expiration: expiration ? expiration.toISOString() : null,
aclTags,
});
getPreAuthKeys: async (user) => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>("GET", "v1/preauthkey", apiKey, { user });
return preAuthKey;
},
return preAuthKeys;
},
expirePreAuthKey: async (user, key) => {
await client.apiFetch<void>('POST', 'v1/preauthkey/expire', apiKey, {
user,
key,
});
},
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
const body: Record<string, unknown> = {
ephemeral,
reusable,
expiration: expiration ? expiration.toISOString() : null,
};
if (user) {
body.user = user;
}
if (aclTags && aclTags.length > 0) {
body.aclTags = aclTags;
}
const { preAuthKey } = await client.apiFetch<{
preAuthKey: PreAuthKey;
}>("POST", "v1/preauthkey", apiKey, body);
return preAuthKey;
},
expirePreAuthKey: async (user, key) => {
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
user,
key,
});
},
}));
+321 -349
View File
@@ -1,179 +1,155 @@
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
import EventEmitter from 'node:events';
import { access, constants, mkdir, open } from 'node:fs/promises';
import { getegid, geteuid } from 'node:process';
import { createInterface, Interface } from 'node:readline';
import { inArray } from 'drizzle-orm';
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver-core';
import { HostInfo } from '~/types';
import log from '~/utils/log';
import { HeadplaneConfig } from './config/config-schema';
import { hostInfo } from './db/schema';
import { inArray } from "drizzle-orm";
import { LibSQLDatabase } from "drizzle-orm/libsql/driver-core";
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import EventEmitter from "node:events";
import { access, constants, mkdir, open } from "node:fs/promises";
import { getegid, geteuid } from "node:process";
import { createInterface, Interface } from "node:readline";
import { HostInfo } from "~/types";
import log from "~/utils/log";
import { HeadplaneConfig } from "./config/config-schema";
import { hostInfo } from "./db/schema";
export async function createHeadplaneAgent(
config: NonNullable<HeadplaneConfig['integration']>['agent'] | undefined,
headscaleUrl: string,
db: LibSQLDatabase,
config: NonNullable<HeadplaneConfig["integration"]>["agent"] | undefined,
headscaleUrl: string,
db: LibSQLDatabase,
) {
if (!config?.enabled) {
return;
}
if (!config?.enabled) {
return;
}
if (!config.pre_authkey) {
log.error('agent', 'Agent `pre_authkey` is not set');
log.warn('agent', 'The agent will not run until resolved');
return;
}
if (!config.pre_authkey) {
log.error("agent", "Agent `pre_authkey` is not set");
log.warn("agent", "The agent will not run until resolved");
return;
}
try {
await access(config.work_dir, constants.R_OK | constants.W_OK);
log.debug('config', 'Using agent work dir at %s', config.work_dir);
} catch (error) {
// Try to create the directory just in case
try {
await mkdir(config.work_dir, { recursive: true });
log.debug('config', 'Created agent work dir at %s', config.work_dir);
log.info(
'config',
'Created missing agent work dir at %s',
config.work_dir,
);
try {
await access(config.work_dir, constants.R_OK | constants.W_OK);
log.debug("config", "Using agent work dir at %s", config.work_dir);
} catch (error) {
// Try to create the directory just in case
try {
await mkdir(config.work_dir, { recursive: true });
log.debug("config", "Created agent work dir at %s", config.work_dir);
log.info("config", "Created missing agent work dir at %s", config.work_dir);
return;
} catch (innerError) {
log.error(
'config',
'Failed to create agent work dir at %s',
config.work_dir,
);
log.info(
'config',
'Agent work dir not accessible at %s',
config.work_dir,
);
log.debug('config', 'Error details: %s', error);
log.debug('config', 'Create error details: %s', innerError);
return;
}
}
return;
} catch (innerError) {
log.error("config", "Failed to create agent work dir at %s", config.work_dir);
log.info("config", "Agent work dir not accessible at %s", config.work_dir);
log.debug("config", "Error details: %s", error);
log.debug("config", "Create error details: %s", innerError);
return;
}
}
try {
const handle = await open(config.cache_path, 'a+');
log.info('agent', 'Using agent cache file at %s', config.cache_path);
await handle.close();
} catch (error) {
log.info(
'agent',
'Agent cache file not accessible at %s',
config.cache_path,
);
log.debug('agent', 'Error details: %s', error);
return;
}
try {
const handle = await open(config.cache_path, "a+");
log.info("agent", "Using agent cache file at %s", config.cache_path);
await handle.close();
} catch (error) {
log.info("agent", "Agent cache file not accessible at %s", config.cache_path);
log.debug("agent", "Error details: %s", error);
return;
}
const agent = new HeadplaneAgent({
...config,
headscaleUrl,
});
const agent = new HeadplaneAgent({
...config,
headscaleUrl,
});
agent.on('spawn', () => {
log.info('agent', 'Headplane agent started');
});
agent.on("spawn", () => {
log.info("agent", "Headplane agent started");
});
agent.on('ready', () => {
log.info('agent', 'Headplane agent is ready and serving queries');
});
agent.on("ready", () => {
log.info("agent", "Headplane agent is ready and serving queries");
});
agent.on('error', (err) => {
log.warn('agent', 'Headplane agent experienced an error: %s', err.message);
log.debug('agent', 'Error details: %o', err);
});
agent.on("error", (err) => {
log.warn("agent", "Headplane agent experienced an error: %s", err.message);
log.debug("agent", "Error details: %o", err);
});
agent.on('exit', ({ code, signal }) => {
log.warn(
'agent',
'Headplane agent exited with code %s and signal %s',
code,
signal,
);
});
agent.on("exit", ({ code, signal }) => {
log.warn("agent", "Headplane agent exited with code %s and signal %s", code, signal);
});
agent.on('restart', ({ delay, attempt }) => {
log.warn(
'agent',
'Headplane agent will restart in %f seconds (attempt %d)',
delay / 1000,
attempt,
);
});
agent.on("restart", ({ delay, attempt }) => {
log.warn(
"agent",
"Headplane agent will restart in %f seconds (attempt %d)",
delay / 1000,
attempt,
);
});
agent.on('stderr', (data) => {
log.error('agent', 'Headplane agent stderr:', data);
});
agent.on("stderr", (data) => {
log.error("agent", "Headplane agent stderr:", data);
});
agent.on('info', async ({ id, info }) => {
log.debug('agent', 'Received HostInfo for %s', id);
try {
const parsedInfo = JSON.parse(info) as HostInfo;
await db
.insert(hostInfo)
.values({
host_id: id,
payload: parsedInfo,
updated_at: new Date(),
})
.onConflictDoUpdate({
target: hostInfo.host_id,
set: {
payload: parsedInfo,
updated_at: new Date(),
},
});
} catch (error) {
log.error(
'agent',
'Failed to parse HostInfo for %s: %s',
id,
error instanceof Error ? error.message : String(error),
);
return;
}
});
agent.on("info", async ({ id, info }) => {
log.debug("agent", "Received HostInfo for %s", id);
try {
const parsedInfo = JSON.parse(info) as HostInfo;
await db
.insert(hostInfo)
.values({
host_id: id,
payload: parsedInfo,
updated_at: new Date(),
})
.onConflictDoUpdate({
target: hostInfo.host_id,
set: {
payload: parsedInfo,
updated_at: new Date(),
},
});
} catch (error) {
log.error(
"agent",
"Failed to parse HostInfo for %s: %s",
id,
error instanceof Error ? error.message : String(error),
);
return;
}
});
agent.start();
agent.start();
process.on('SIGTERM', () => agent.shutdown());
process.on('SIGINT', () => agent.shutdown());
process.on("SIGTERM", () => agent.shutdown());
process.on("SIGINT", () => agent.shutdown());
return {
agentID: () => agent.agentID(),
lookup: async (nodes: string[]) => {
const results = await db
.select()
.from(hostInfo)
.where(inArray(hostInfo.host_id, nodes));
return {
agentID: () => agent.agentID(),
lookup: async (nodes: string[]) => {
const results = await db.select().from(hostInfo).where(inArray(hostInfo.host_id, nodes));
return Object.fromEntries(
results.filter((r) => r.payload).map((r) => [r.host_id, r.payload]),
) as Record<string, HostInfo>;
},
};
return Object.fromEntries(
results.filter((r) => r.payload).map((r) => [r.host_id, r.payload]),
) as Record<string, HostInfo>;
},
};
}
type AgentOptions = NonNullable<
NonNullable<HeadplaneConfig['integration']>['agent']
> & {
headscaleUrl: string;
type AgentOptions = NonNullable<NonNullable<HeadplaneConfig["integration"]>["agent"]> & {
headscaleUrl: string;
};
interface AgentEvents {
ready: [];
spawn: [];
error: [Error];
exit: [{ code?: number; signal?: NodeJS.Signals }];
restart: [{ delay: number; attempt: number }];
stderr: [string];
info: [{ id: string; info: string }];
ready: [];
spawn: [];
error: [Error];
exit: [{ code?: number; signal?: NodeJS.Signals }];
restart: [{ delay: number; attempt: number }];
stderr: [string];
info: [{ id: string; info: string }];
}
/**
@@ -184,237 +160,233 @@ interface AgentEvents {
* - The agent is restarted on a backoff strategy
*/
class HeadplaneAgent extends EventEmitter<AgentEvents> {
private child?: ChildProcessWithoutNullStreams;
private readline?: Interface;
private child?: ChildProcessWithoutNullStreams;
private readline?: Interface;
private options: AgentOptions;
private options: AgentOptions;
private hbInterval?: NodeJS.Timeout;
private hbDeadline?: NodeJS.Timeout;
private restartTimer?: NodeJS.Timeout;
private isWaitingForAck = false;
private isShuttingDown = false;
private backoffAttempt = 0;
private agentId?: string;
private hbInterval?: NodeJS.Timeout;
private hbDeadline?: NodeJS.Timeout;
private restartTimer?: NodeJS.Timeout;
private refreshInterval?: NodeJS.Timeout;
private isWaitingForAck = false;
private isShuttingDown = false;
private backoffAttempt = 0;
private agentId?: string;
private BASE_BACKOFF_MS = 1.5 * 1000; // 1.5 seconds
private MAX_BACKOFF_MS = 30 * 1000; // 30 seconds
private PROBE_COOLDOWN_MS = 5 * 60_000; // 5 minutes
private PROBE_ATTEMPT_INTERVAL = 10; // Every 10th attempt
private BASE_BACKOFF_MS = 1.5 * 1000; // 1.5 seconds
private MAX_BACKOFF_MS = 30 * 1000; // 30 seconds
private PROBE_COOLDOWN_MS = 5 * 60_000; // 5 minutes
private PROBE_ATTEMPT_INTERVAL = 10; // Every 10th attempt
private HEARTBEAT_INTERVAL_MS = 5 * 1000; // 5 seconds
private HEARTBEAT_TIMEOUT_MS = 3 * 1000; // 3 seconds
private HEARTBEAT_INTERVAL_MS = 5 * 1000; // 5 seconds
private HEARTBEAT_TIMEOUT_MS = 3 * 1000; // 3 seconds
constructor(options: AgentOptions) {
super();
this.options = options;
}
constructor(options: AgentOptions) {
super();
this.options = options;
}
agentID() {
return this.agentId;
}
agentID() {
return this.agentId;
}
start() {
this.isShuttingDown = false;
this.spawnInternalChild();
}
start() {
this.isShuttingDown = false;
this.spawnInternalChild();
}
shutdown() {
this.isShuttingDown = true;
this.agentId = undefined;
shutdown() {
this.isShuttingDown = true;
this.agentId = undefined;
clearTimeout(this.restartTimer);
clearInterval(this.hbInterval);
clearTimeout(this.hbDeadline);
this.isWaitingForAck = false;
clearTimeout(this.restartTimer);
clearInterval(this.hbInterval);
clearInterval(this.refreshInterval);
clearTimeout(this.hbDeadline);
this.isWaitingForAck = false;
this.send('SHUTDOWN');
this.child?.kill('SIGTERM');
this.readline?.close();
}
this.send("SHUTDOWN");
this.child?.kill("SIGTERM");
this.readline?.close();
}
private spawnInternalChild() {
this.child = spawn(this.options.executable_path, {
stdio: ['pipe', 'pipe', 'pipe'],
uid: geteuid?.() ?? undefined,
gid: getegid?.() ?? undefined,
env: {
HOME: process.env.HOME,
HEADPLANE_AGENT_WORK_DIR: this.options.work_dir,
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? 'true' : 'false',
HEADPLANE_AGENT_HOSTNAME: this.options.host_name,
HEADPLANE_AGENT_TS_SERVER: this.options.headscaleUrl,
HEADPLANE_AGENT_TS_AUTHKEY: this.options.pre_authkey,
},
});
private spawnInternalChild() {
this.child = spawn(this.options.executable_path, {
stdio: ["pipe", "pipe", "pipe"],
uid: geteuid?.() ?? undefined,
gid: getegid?.() ?? undefined,
env: {
HOME: process.env.HOME,
HEADPLANE_AGENT_WORK_DIR: this.options.work_dir,
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? "true" : "false",
HEADPLANE_AGENT_HOSTNAME: this.options.host_name,
HEADPLANE_AGENT_TS_SERVER: this.options.headscaleUrl,
HEADPLANE_AGENT_TS_AUTHKEY: this.options.pre_authkey,
},
});
this.emit('spawn');
this.child.on('error', (err) => this.emit('error', err));
this.child.stderr.on('data', (data) =>
this.emit('stderr', data.toString()),
);
this.emit("spawn");
this.child.on("error", (err) => this.emit("error", err));
this.child.stderr.on("data", (data) => this.emit("stderr", data.toString()));
this.child.on('exit', (code, signal) => {
this.agentId = undefined;
this.emit('exit', {
code: code ?? undefined,
signal: signal ?? undefined,
});
this.child.on("exit", (code, signal) => {
this.agentId = undefined;
this.emit("exit", {
code: code ?? undefined,
signal: signal ?? undefined,
});
this.readline?.close();
clearInterval(this.hbInterval);
clearTimeout(this.hbDeadline);
this.isWaitingForAck = false;
this.readline?.close();
clearInterval(this.hbInterval);
clearInterval(this.refreshInterval);
clearTimeout(this.hbDeadline);
this.isWaitingForAck = false;
if (this.isShuttingDown) {
log.info('agent', 'Child process exited gracefully');
return;
}
if (this.isShuttingDown) {
log.info("agent", "Child process exited gracefully");
return;
}
this.backoffAttempt++;
const delay = this.calculateBackoff();
this.emit('restart', { delay, attempt: this.backoffAttempt });
this.restartTimer = setTimeout(() => this.spawnInternalChild(), delay);
});
this.backoffAttempt++;
const delay = this.calculateBackoff();
this.emit("restart", { delay, attempt: this.backoffAttempt });
this.restartTimer = setTimeout(() => this.spawnInternalChild(), delay);
});
this.readline = createInterface({ input: this.child.stdout });
this.readline.on('line', (line) => this.readlineHandler(line));
this.send('START');
this.readline = createInterface({ input: this.child.stdout });
this.readline.on("line", (line) => this.readlineHandler(line));
this.send("START");
// Start the heartbeat loop with our custom interval
this.hbInterval = setInterval(() => {
if (!this.child || this.child.killed) return;
// Start the heartbeat loop with our custom interval
this.hbInterval = setInterval(() => {
if (!this.child || this.child.killed) return;
// If we get here, we missed the last PONG response and can die
if (this.isWaitingForAck) {
this.agentId = undefined;
this.emit('error', new Error('Agent heartbeat missed'));
this.child.kill('SIGTERM');
return;
}
// If we get here, we missed the last PONG response and can die
if (this.isWaitingForAck) {
this.agentId = undefined;
this.emit("error", new Error("Agent heartbeat missed"));
this.child.kill("SIGTERM");
return;
}
this.isWaitingForAck = true;
this.send('PING');
this.isWaitingForAck = true;
this.send("PING");
clearTimeout(this.hbDeadline);
this.hbDeadline = setTimeout(() => {
if (this.isWaitingForAck) {
this.agentId = undefined;
this.emit('error', new Error('Agent heartbeat timeout'));
this.child?.kill('SIGTERM');
}
}, this.HEARTBEAT_TIMEOUT_MS);
}, this.HEARTBEAT_INTERVAL_MS);
}
clearTimeout(this.hbDeadline);
this.hbDeadline = setTimeout(() => {
if (this.isWaitingForAck) {
this.agentId = undefined;
this.emit("error", new Error("Agent heartbeat timeout"));
this.child?.kill("SIGTERM");
}
}, this.HEARTBEAT_TIMEOUT_MS);
}, this.HEARTBEAT_INTERVAL_MS);
}
private send(s: string) {
if (!this.child || this.child.killed) return;
const ok = this.child.stdin.write(`${s}\n`);
if (!ok) this.child.stdin.once('drain', () => {});
}
private send(s: string) {
if (!this.child || this.child.killed) return;
const ok = this.child.stdin.write(`${s}\n`);
if (!ok) this.child.stdin.once("drain", () => {});
}
/**
* Calculates a backoff time based on the current attempt.
* Supports a randomized jitter to avoid thundering herd problems.
*
* @param min The minimum backoff time in milliseconds.
* @param max The maximum backoff time in milliseconds.
* @returns The calculated backoff time in milliseconds.
*/
private calculateBackoff() {
const attempt = this.backoffAttempt;
if (attempt > 0 && attempt % this.PROBE_ATTEMPT_INTERVAL === 0) {
const jitter = Math.floor(Math.random() * (this.MAX_BACKOFF_MS + 1));
const sign = Math.random() < 0.5 ? -1 : 1;
/**
* Calculates a backoff time based on the current attempt.
* Supports a randomized jitter to avoid thundering herd problems.
*
* @param min The minimum backoff time in milliseconds.
* @param max The maximum backoff time in milliseconds.
* @returns The calculated backoff time in milliseconds.
*/
private calculateBackoff() {
const attempt = this.backoffAttempt;
if (attempt > 0 && attempt % this.PROBE_ATTEMPT_INTERVAL === 0) {
const jitter = Math.floor(Math.random() * (this.MAX_BACKOFF_MS + 1));
const sign = Math.random() < 0.5 ? -1 : 1;
return Math.max(0, this.PROBE_COOLDOWN_MS + jitter * sign);
}
return Math.max(0, this.PROBE_COOLDOWN_MS + jitter * sign);
}
const cap = Math.min(
this.MAX_BACKOFF_MS,
this.BASE_BACKOFF_MS * 2 ** attempt,
);
const cap = Math.min(this.MAX_BACKOFF_MS, this.BASE_BACKOFF_MS * 2 ** attempt);
return Math.floor(Math.random() * (cap + 1));
}
return Math.floor(Math.random() * (cap + 1));
}
/**
* Processes and dispatches the appropriate response based on the message.
* @param line The message to process (piped straight from readline)
*/
private readlineHandler(line: string) {
// When we are ready we force a refresh so that the UI has the most
// up-to-date information and will gracefully handle new info being sent
if (line.startsWith('READY')) {
this.backoffAttempt = 0;
this.send('REFRESH');
this.emit('ready');
/**
* Processes and dispatches the appropriate response based on the message.
* @param line The message to process (piped straight from readline)
*/
private readlineHandler(line: string) {
// When we are ready we force a refresh so that the UI has the most
// up-to-date information and will gracefully handle new info being sent
if (line.startsWith("READY")) {
this.backoffAttempt = 0;
this.send("REFRESH");
this.emit("ready");
const agentId = line.slice(5).trim();
if (this.agentId && this.agentId !== agentId) {
log.warn(
'agent',
'Agent ID changed from %s to %s',
this.agentId,
agentId,
);
}
// Start periodic refresh using cache_ttl
clearInterval(this.refreshInterval);
this.refreshInterval = setInterval(() => {
if (!this.child || this.child.killed) return;
log.debug("agent", "Sending periodic REFRESH");
this.send("REFRESH");
}, this.options.cache_ttl);
this.agentId = agentId;
return;
}
const agentId = line.slice(5).trim();
if (this.agentId && this.agentId !== agentId) {
log.warn("agent", "Agent ID changed from %s to %s", this.agentId, agentId);
}
if (line.startsWith('PONG')) {
this.isWaitingForAck = false;
clearTimeout(this.hbDeadline);
this.agentId = agentId;
return;
}
const agentId = line.slice(5).trim();
if (this.agentId && this.agentId !== agentId) {
log.warn(
'agent',
'Agent ID changed from %s to %s',
this.agentId,
agentId,
);
}
if (line.startsWith("PONG")) {
this.isWaitingForAck = false;
clearTimeout(this.hbDeadline);
this.agentId = agentId;
return;
}
const agentId = line.slice(5).trim();
if (this.agentId && this.agentId !== agentId) {
log.warn("agent", "Agent ID changed from %s to %s", this.agentId, agentId);
}
if (line.startsWith('HOSTINFO')) {
const data = line.slice(9).trim();
const [id, ...infoParts] = data.split(' ');
const info = infoParts.join(' ');
this.emit('info', { id, info });
return;
}
this.agentId = agentId;
return;
}
if (line.startsWith('ERROR')) {
const error = line.slice(6).trim();
this.emit('error', new Error(error));
return;
}
if (line.startsWith("HOSTINFO")) {
const data = line.slice(9).trim();
const [id, ...infoParts] = data.split(" ");
const info = infoParts.join(" ");
this.emit("info", { id, info });
return;
}
if (line.startsWith('LOG')) {
const logSnippet = line.slice(4).trim();
const [level, ...messageParts] = logSnippet.split(' ');
const message = messageParts.join(' ');
switch (level) {
case 'INFO':
log.info('agent', message);
break;
case 'WARN':
log.warn('agent', message);
break;
case 'ERROR':
log.error('agent', message);
break;
default:
log.debug('agent', message);
}
if (line.startsWith("ERROR")) {
const error = line.slice(6).trim();
this.emit("error", new Error(error));
return;
}
return;
}
}
if (line.startsWith("LOG")) {
const logSnippet = line.slice(4).trim();
const [level, ...messageParts] = logSnippet.split(" ");
const message = messageParts.join(" ");
switch (level) {
case "INFO":
log.info("agent", message);
break;
case "WARN":
log.warn("agent", message);
break;
case "ERROR":
log.error("agent", message);
break;
default:
log.debug("agent", message);
}
return;
}
}
}
+22 -11
View File
@@ -1,5 +1,6 @@
import { join } from "node:path";
import { exit, versions } from "node:process";
import { createHonoServer } from "react-router-hono-server/node";
import log from "~/utils/log";
@@ -10,7 +11,7 @@ import { createDbClient } from "./db/client.server";
import { createHeadscaleInterface } from "./headscale/api";
import { loadHeadscaleConfig } from "./headscale/config-loader";
import { createHeadplaneAgent } from "./hp-agent";
import { createSessionStorage } from "./web/sessions";
import { createAuthService } from "./web/auth";
declare global {
const __PREFIX__: string;
@@ -60,11 +61,9 @@ const appLoadContext = {
config.headscale.dns_records_path,
),
// TODO: Better cookie options in config
sessions: await createSessionStorage({
auth: createAuthService({
secret: config.server.cookie_secret,
db,
oidcUsersFile: config.oidc?.user_storage_file,
cookie: {
name: "_hp_auth",
secure: config.server.cookie_secure,
@@ -76,13 +75,17 @@ const appLoadContext = {
hsApi,
agents,
integration: await loadIntegration(config.integration),
oidcConnector: config.oidc
? createLazyOidcConnector(
config.server.base_url,
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
: undefined,
oidc:
config.oidc && config.oidc.enabled !== false
? {
apiKey: config.oidc.headscale_api_key,
connector: createLazyOidcConnector(
config.server.base_url,
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
),
}
: undefined,
db,
};
@@ -126,6 +129,14 @@ export default createHonoServer({
},
});
// Prune expired auth sessions every 15 minutes
setInterval(
() => {
appLoadContext.auth.pruneExpiredSessions();
},
15 * 60 * 1000,
);
process.on("SIGINT", () => {
log.info("server", "Received SIGINT, shutting down...");
process.exit(0);
+480
View File
@@ -0,0 +1,480 @@
import { createHash, createHmac } from "node:crypto";
import { eq, lt, sql } from "drizzle-orm";
import { LibSQLDatabase } from "drizzle-orm/libsql/driver";
import { createCookie } from "react-router";
import { ulid } from "ulidx";
import type { Machine } from "~/types";
import { authSessions, users } from "../db/schema";
import { Capabilities, type Role, Roles, capsForRole } from "./roles";
// ── Principal ────────────────────────────────────────────────────────
// The per-request identity object. Discriminated on `kind` so routes
// can branch structurally instead of checking magic strings.
export type Principal =
| {
kind: "api_key";
sessionId: string;
displayName: string;
apiKey: string;
}
| {
kind: "oidc";
sessionId: string;
user: {
id: string;
subject: string;
role: Role;
headscaleUserId: string | undefined;
onboarded: boolean;
};
profile: {
name: string;
email?: string;
username?: string;
picture?: string;
};
};
// ── Cookie payload ───────────────────────────────────────────────────
// The cookie contains only a session ID + minimal profile data for
// SSR rendering. Credentials never leave the server.
interface CookiePayload {
sid: string;
// API key is stored in the cookie ONLY for api_key sessions.
// OIDC sessions use the server-side oidc.headscale_api_key.
api_key?: string;
profile?: {
name: string;
email?: string;
username?: string;
picture?: string;
};
}
// ── AuthService ──────────────────────────────────────────────────────
export interface AuthServiceOptions {
secret: string;
db: LibSQLDatabase;
cookie: {
name: string;
secure: boolean;
maxAge: number;
domain?: string;
};
}
export class AuthService {
private opts: AuthServiceOptions;
private requestCache = new WeakMap<Request, Promise<Principal>>();
constructor(opts: AuthServiceOptions) {
this.opts = opts;
}
// ── Authentication ─────────────────────────────────────────────
/**
* Resolve the principal for a request. Throws if no valid session.
* Results are cached per-request so multiple calls in the same
* loader don't hit the DB repeatedly.
*/
require(request: Request): Promise<Principal> {
const cached = this.requestCache.get(request);
if (cached) {
return cached;
}
const promise = this.resolve(request);
this.requestCache.set(request, promise);
return promise;
}
private async resolve(request: Request): Promise<Principal> {
const payload = await this.decodeCookie(request);
const [session] = await this.opts.db
.select()
.from(authSessions)
.where(eq(authSessions.id, payload.sid))
.limit(1);
if (!session) {
throw new Error("Session not found");
}
if (session.expires_at < new Date()) {
await this.opts.db.delete(authSessions).where(eq(authSessions.id, session.id));
throw new Error("Session expired");
}
if (session.kind === "api_key") {
if (!payload.api_key) {
throw new Error("API key session missing credential");
}
return {
kind: "api_key",
sessionId: session.id,
displayName: session.api_key_display ?? "API Key",
apiKey: payload.api_key,
};
}
if (!session.user_id) {
throw new Error("OIDC session missing user_id");
}
const [user] = await this.opts.db
.select()
.from(users)
.where(eq(users.id, session.user_id))
.limit(1);
if (!user) {
throw new Error("User record not found");
}
const role = (user.role in Roles ? user.role : "member") as Role;
return {
kind: "oidc",
sessionId: session.id,
user: {
id: user.id,
subject: user.sub,
role,
headscaleUserId: user.headscale_user_id ?? undefined,
onboarded: user.onboarded,
},
profile: payload.profile ?? {
name: user.sub,
},
};
}
// ── Authorization ──────────────────────────────────────────────
/**
* Check if a principal has a given set of capabilities.
* API key principals always have full access.
*/
can(principal: Principal, capabilities: Capabilities): boolean {
if (principal.kind === "api_key") {
return true;
}
const roleCaps = Roles[principal.user.role];
return (capabilities & roleCaps) === capabilities;
}
/**
* Check if a principal can act on a machine. Owners of the machine
* can act on it even without write_machines capability.
*/
canManageNode(principal: Principal, node: Machine): boolean {
if (principal.kind === "api_key") {
return true;
}
const caps = Roles[principal.user.role];
if ((caps & Capabilities.write_machines) !== 0) {
return true;
}
const hsUserId = principal.user.headscaleUserId;
return hsUserId !== undefined && node.user?.id === hsUserId;
}
// ── Session management ─────────────────────────────────────────
/**
* Create a new OIDC session. Returns the Set-Cookie header value.
*/
async createOidcSession(
userId: string,
profile: NonNullable<CookiePayload["profile"]>,
maxAge = this.opts.cookie.maxAge,
): Promise<string> {
const sid = ulid();
await this.opts.db.insert(authSessions).values({
id: sid,
kind: "oidc",
user_id: userId,
expires_at: new Date(Date.now() + maxAge * 1000),
});
return this.encodeCookie({ sid, profile }, maxAge);
}
/**
* Create a new API key session. A SHA-256 hash of the key is stored
* server-side for auditing. The plaintext key is carried in the
* HMAC-signed cookie so it can be used for Headscale API calls.
* Returns the Set-Cookie header value.
*/
async createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string> {
const sid = ulid();
await this.opts.db.insert(authSessions).values({
id: sid,
kind: "api_key",
api_key_hash: this.hashApiKey(apiKey),
api_key_display: displayName,
expires_at: new Date(Date.now() + maxAge),
});
return this.encodeCookie({ sid, api_key: apiKey }, Math.floor(maxAge / 1000));
}
/**
* Get the Headscale API key for making API calls.
* OIDC sessions use the configured oidc.headscale_api_key.
* API key sessions use the user-provided key stored in the cookie.
*/
getHeadscaleApiKey(principal: Principal, oidcApiKey?: string): string {
if (principal.kind === "api_key") {
return principal.apiKey;
}
if (!oidcApiKey) {
throw new Error("OIDC sessions require oidc.headscale_api_key");
}
return oidcApiKey;
}
/**
* Destroy the current session. Returns the Set-Cookie header that
* clears the cookie.
*/
async destroySession(request?: Request): Promise<string> {
if (request) {
try {
const payload = await this.decodeCookie(request);
await this.opts.db.delete(authSessions).where(eq(authSessions.id, payload.sid));
} catch {
// Cookie already invalid, just clear it
}
}
const cookie = createCookie(this.opts.cookie.name, {
...this.opts.cookie,
path: __PREFIX__,
});
return cookie.serialize("", { expires: new Date(0) });
}
// ── User management ────────────────────────────────────────────
/**
* Find or create a Headplane user by OIDC subject. Returns the
* user ID. The first user ever created is automatically granted
* the owner role (bootstrap). Uses upsert to avoid race conditions.
*/
async findOrCreateUser(subject: string): Promise<string> {
const [existing] = await this.opts.db
.select()
.from(users)
.where(eq(users.sub, subject))
.limit(1);
if (existing) {
await this.opts.db
.update(users)
.set({ last_login_at: new Date(), updated_at: new Date() })
.where(eq(users.id, existing.id));
return existing.id;
}
const id = ulid();
await this.opts.db.insert(users).values({
id,
sub: subject,
role: "member",
caps: capsForRole("member"),
onboarded: false,
});
// If this is the only user in the table, promote to owner.
// The unique constraint on `sub` prevents two concurrent inserts
// for the same subject; for different subjects, COUNT atomically
// reflects all committed rows so at most one will see count === 1.
const [{ count }] = await this.opts.db.select({ count: sql<number>`count(*)` }).from(users);
if (count === 1) {
await this.opts.db
.update(users)
.set({ role: "owner", caps: capsForRole("owner") })
.where(eq(users.id, id));
}
return id;
}
/**
* Link a Headplane user to a Headscale user. Returns false if the
* Headscale user is already claimed by another Headplane user.
*/
async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
const [existing] = await this.opts.db
.select({ id: users.id })
.from(users)
.where(eq(users.headscale_user_id, headscaleUserId))
.limit(1);
if (existing && existing.id !== userId) {
return false;
}
await this.opts.db
.update(users)
.set({ headscale_user_id: headscaleUserId, updated_at: new Date() })
.where(eq(users.id, userId));
return true;
}
/**
* Link a Headplane user (identified by OIDC subject) to a Headscale
* user. Used by admin UI when subjects are more accessible than
* internal Headplane IDs. Returns false if already claimed.
*/
async linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise<boolean> {
const [user] = await this.opts.db
.select({ id: users.id })
.from(users)
.where(eq(users.sub, subject))
.limit(1);
if (!user) {
return false;
}
return this.linkHeadscaleUser(user.id, headscaleUserId);
}
/**
* Returns the set of Headscale user IDs that are already claimed
* by a Headplane user. Used to filter the onboarding dropdown.
*/
async claimedHeadscaleUserIds(): Promise<Set<string>> {
const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users);
const ids = new Set<string>();
for (const row of rows) {
if (row.hsId) {
ids.add(row.hsId);
}
}
return ids;
}
/**
* Get the role for a given OIDC subject. Used by the users overview
* to display roles for Headscale users.
*/
async roleForSubject(subject: string): Promise<Role | undefined> {
const [user] = await this.opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
if (!user) {
return;
}
return (user.role in Roles ? user.role : "member") as Role;
}
/**
* Reassign the role of a user identified by their OIDC subject.
* Cannot reassign the owner role.
*/
async reassignSubject(subject: string, role: Role): Promise<boolean> {
const currentRole = await this.roleForSubject(subject);
if (currentRole === "owner") {
return false;
}
await this.opts.db
.insert(users)
.values({
id: ulid(),
sub: subject,
role,
caps: capsForRole(role),
onboarded: false,
})
.onConflictDoUpdate({
target: users.sub,
set: { role, caps: capsForRole(role), updated_at: new Date() },
});
return true;
}
/**
* Clean up expired sessions. Should be called periodically.
*/
async pruneExpiredSessions(): Promise<void> {
await this.opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date()));
}
// ── Private helpers ────────────────────────────────────────────
private async encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
const cookie = createCookie(this.opts.cookie.name, {
...this.opts.cookie,
path: __PREFIX__,
maxAge,
});
const signed = Buffer.from(JSON.stringify(payload)).toString("base64url");
const hmac = createHmac("sha256", this.opts.secret).update(signed).digest("base64url");
return cookie.serialize(`${signed}.${hmac}`);
}
private async decodeCookie(request: Request): Promise<CookiePayload> {
const cookieHeader = request.headers.get("cookie");
if (!cookieHeader) {
throw new Error("No session cookie found");
}
const cookie = createCookie(this.opts.cookie.name, {
...this.opts.cookie,
path: __PREFIX__,
});
const raw = (await cookie.parse(cookieHeader)) as string | null;
if (!raw) {
throw new Error("Session cookie is empty");
}
const dotIndex = raw.lastIndexOf(".");
if (dotIndex === -1) {
throw new Error("Malformed session cookie");
}
const signed = raw.slice(0, dotIndex);
const hmac = raw.slice(dotIndex + 1);
const expected = createHmac("sha256", this.opts.secret).update(signed).digest("base64url");
if (hmac !== expected) {
throw new Error("Invalid session cookie signature");
}
return JSON.parse(Buffer.from(signed, "base64url").toString("utf-8")) as CookiePayload;
}
private hashApiKey(key: string): string {
return createHash("sha256").update(key).digest("hex");
}
}
export function createAuthService(opts: AuthServiceOptions): AuthService {
return new AuthService(opts);
}
+39
View File
@@ -0,0 +1,39 @@
import type { User } from "~/types/User";
/**
* Extracts the OIDC subject from a Headscale user's providerId.
* Headscale stores providerId as a URL where the last path segment
* is the subject (e.g. "https://idp.example.com/<uuid>"). This is
* the ONLY place this parsing should occur — all other code should
* use the stable headscale_user_id link on the Headplane user record.
*/
export function getOidcSubject(user: User): string | undefined {
if (user.provider !== "oidc" || !user.providerId) {
return;
}
return user.providerId.split("/").pop();
}
/**
* Finds the Headscale user matching the given OIDC identity.
* Tries subject match first (providerId last segment), then falls
* back to email match. The fallback is needed because some IDPs
* issue different subjects per client application.
*/
export function findHeadscaleUserBySubject(
users: User[],
subject: string,
email?: string,
): User | undefined {
const bySubject = users.find((u) => getOidcSubject(u) === subject);
if (bySubject) {
return bySubject;
}
if (!email) {
return;
}
return users.find((u) => u.email === email);
}
+103 -124
View File
@@ -1,144 +1,123 @@
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
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,
ui_access: 1 << 0,
read_policy: 1 << 1,
write_policy: 1 << 2,
read_network: 1 << 3,
write_network: 1 << 4,
read_feature: 1 << 5,
write_feature: 1 << 6,
configure_iam: 1 << 7,
read_machines: 1 << 8,
write_machines: 1 << 9,
read_users: 1 << 10,
write_users: 1 << 11,
generate_authkeys: 1 << 12,
generate_own_authkeys: 1 << 16,
use_tags: 1 << 13,
write_tailnet: 1 << 14,
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,
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,
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,
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,
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,
auditor:
Capabilities.ui_access |
Capabilities.read_policy |
Capabilities.read_network |
Capabilities.read_feature |
Capabilities.read_machines |
Capabilities.read_users |
Capabilities.generate_own_authkeys,
// Default role for new users with 0 capabilities on the UI side of things
member: 0,
viewer:
Capabilities.ui_access |
Capabilities.read_machines |
Capabilities.read_users |
Capabilities.generate_own_authkeys,
// No access — user exists but has not been granted any role
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;
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;
}
}
const iterable = Roles as Record<string, Capabilities>;
for (const role in iterable) {
if (iterable[role] === capabilities) {
return role as Role;
}
}
return 'member';
return "member";
}
export function capsForRole(role: Role): number {
return Roles[role];
}
-317
View File
@@ -1,317 +0,0 @@
import { createHash } from 'node:crypto';
import { open, readFile, rm } from 'node:fs/promises';
import { resolve } from 'node:path';
import { eq } from 'drizzle-orm';
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver';
import { EncryptJWT, jwtDecrypt } from 'jose';
import { createCookie } from 'react-router';
import { ulid } from 'ulidx';
import log from '~/utils/log';
import { users } from '../db/schema';
import { Capabilities, Roles } from './roles';
export interface AuthSession {
state: 'auth';
api_key: string;
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
}
interface JWTSession {
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;
};
}
interface AuthSessionOptions {
secret: string;
db: LibSQLDatabase;
oidcUsersFile?: string;
cookie: {
name: string;
secure: boolean;
maxAge: number;
domain?: string;
};
}
class Sessionizer {
private options: AuthSessionOptions;
constructor(options: AuthSessionOptions) {
this.options = options;
}
// 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) {
return decodeSession(request, this.options);
}
async createSession(
payload: JWTSession,
maxAge = this.options.cookie.maxAge,
) {
// TODO: What the hell is this garbage
return createSession(payload, {
...this.options,
cookie: {
...this.options.cookie,
maxAge,
},
});
}
async destroySession() {
return destroySession(this.options);
}
async roleForSubject(
subject: string,
): Promise<keyof typeof Roles | undefined> {
const [user] = await this.options.db
.select()
.from(users)
.where(eq(users.sub, subject))
.limit(1);
if (!user) {
return;
}
// We need this in string form based on Object.keys of the roles
for (const [key, value] of Object.entries(Roles)) {
if (value === user.caps) {
return key as keyof typeof Roles;
}
}
}
// 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);
// This is the subject we set on API key based sessions. API keys
// inherently imply admin access so we return true for all checks.
if (session.user.subject === 'unknown-non-oauth') {
return true;
}
const [user] = await this.options.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
if (!user) {
return false;
}
return (capabilities & user.caps) === capabilities;
}
// Updates the capabilities and roles of a subject
async reassignSubject(subject: string, role: keyof typeof Roles) {
// Check if we are owner
const subjectRole = await this.roleForSubject(subject);
if (subjectRole === 'owner') {
return false;
}
await this.options.db
.update(users)
.set({
caps: Roles[role],
})
.where(eq(users.sub, subject));
return true;
}
}
async function createSession(payload: JWTSession, options: AuthSessionOptions) {
const now = Math.floor(Date.now() / 1000);
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
const jwt = await new EncryptJWT({
...payload,
})
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' })
.setIssuedAt()
.setExpirationTime(now + options.cookie.maxAge)
.setIssuer('urn:tale:headplane')
.setAudience('urn:tale:headplane')
.setJti(ulid())
.encrypt(secret);
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
return cookie.serialize(jwt);
}
async function decodeSession(request: Request, options: AuthSessionOptions) {
const cookieHeader = request.headers.get('cookie');
if (cookieHeader === null) {
throw new Error('No session cookie found');
}
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
if (cookieValue === null) {
throw new Error('Session cookie is empty');
}
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
const { payload } = await jwtDecrypt(cookieValue, secret, {
issuer: 'urn:tale:headplane',
audience: 'urn:tale:headplane',
});
// Safe since we encode the session directly into the JWT
return payload as unknown as JWTSession;
}
async function destroySession(options: AuthSessionOptions) {
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
return cookie.serialize('', {
expires: new Date(0),
});
}
export async function createSessionStorage(options: AuthSessionOptions) {
if (options.oidcUsersFile) {
await migrateUserDatabase(options.oidcUsersFile, options.db);
}
return new Sessionizer(options);
}
async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
const realPath = resolve(path);
try {
const handle = await open(realPath, 'a+');
await handle.close();
} catch (error) {
if (
error != null &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
log.debug('config', 'No old user database file found at %s', realPath);
return;
}
log.warn('config', 'Failed to migrate old user database at %s', realPath);
log.warn(
'config',
'This is not an error, but existing users will not be migrated',
);
log.warn('config', 'Unable to open user database file: %s', String(error));
log.debug('config', 'Error details: %s', error);
return;
}
log.info('config', 'Found old user database file at %s', realPath);
log.info('config', 'Migrating user database to the new SQL database');
let migratableUsers: {
u: string;
c: number;
oo?: boolean;
}[];
try {
const data = await readFile(realPath, 'utf8');
if (data.trim().length === 0) {
log.info('config', 'Old user database file is empty, nothing to migrate');
log.info(
'config',
'You SHOULD remove oidc.user_storage_file from your config!',
);
await rm(realPath, { force: true });
return;
}
const users = JSON.parse(data.trim()) as {
u?: string;
c?: number;
oo?: boolean;
}[];
migratableUsers = users.filter(
(user) => user.u !== undefined && user.c !== undefined,
) as {
u: string;
c: number;
oo?: boolean;
}[];
} catch (error) {
log.warn('config', 'Error reading old user database file: %s', error);
log.warn('config', 'Not migrating any users');
return;
}
if (migratableUsers.length === 0) {
log.info('config', 'No users found in the old database to migrate');
return;
}
log.info(
'config',
'Migrating %d users from the old database',
migratableUsers.length,
);
const updated = await db
.insert(users)
.values(
migratableUsers.map((user) => ({
id: ulid(),
sub: user.u,
caps: user.c,
onboarded: user.oo ?? false,
})),
)
.onConflictDoNothing({
target: users.sub,
})
.returning();
log.info('config', 'Migrated %d users successfully', updated.length);
log.info('config', 'Removed old user database file %s', realPath);
await rm(realPath, { force: true });
}
+2 -1
View File
@@ -9,7 +9,8 @@ export interface Machine {
ipAddresses: string[];
name: string;
user: User;
// User can be null for tag-only nodes in Headscale 0.28+
user?: User;
lastSeen: string;
expiry: string | null;
+1 -1
View File
@@ -3,7 +3,7 @@ import type { User } from './User';
export interface PreAuthKey {
id: string;
key: string;
user: User;
user: User | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
+5
View File
@@ -0,0 +1,5 @@
import type { User } from "~/types/User";
export function getUserDisplayName(user: User): string {
return user.name || user.displayName || user.email || user.id;
}
+49 -45
View File
@@ -167,58 +167,62 @@ integration:
# OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience)
# oidc:
# The OIDC issuer URL
# issuer: "https://accounts.google.com"
# Set to false to define OIDC config without enabling it.
# Useful for Helm charts or generating docs from config files.
# enabled: true
# If you are using OIDC, you need to generate an API key
# that can be used to authenticate other sessions when signing in.
#
# This can be done with `headscale apikeys create --expiration 999d`
# headscale_api_key: "<your-headscale-api-key>"
# The OIDC issuer URL
# issuer: "https://accounts.google.com"
# If your OIDC provider does not support discovery (does not have the URL at
# `/.well-known/openid-configuration`), you need to manually set endpoints.
# This also works to override endpoints if you so desire or if your OIDC
# discovery is missing certain endpoints (ie GitHub).
# For some typical providers, see https://headplane.net/features/sso.
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# If you are using OIDC, you need to generate an API key
# that can be used to authenticate other sessions when signing in.
#
# This can be done with `headscale apikeys create --expiration 999d`
# headscale_api_key: "<your-headscale-api-key>"
# The authentication method to use when communicating with the token endpoint.
# This is fully optional and Headplane will attempt to auto-detect the best
# method and fall back to `client_secret_basic` if unsure.
# token_endpoint_auth_method: "client_secret_post"
# If your OIDC provider does not support discovery (does not have the URL at
# `/.well-known/openid-configuration`), you need to manually set endpoints.
# This also works to override endpoints if you so desire or if your OIDC
# discovery is missing certain endpoints (ie GitHub).
# For some typical providers, see https://headplane.net/features/sso.
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# The client ID for the OIDC client
# For the best experience please ensure this is *identical* to the client_id
# you are using for Headscale. because
# client_id: "your-client-id"
# The authentication method to use when communicating with the token endpoint.
# This is fully optional and Headplane will attempt to auto-detect the best
# method and fall back to `client_secret_basic` if unsure.
# token_endpoint_auth_method: "client_secret_post"
# The client secret for the OIDC client
# You may also provide `client_secret_path` instead to read a value from disk.
# See https://headplane.net/configuration/#sensitive-values
# client_secret: "<your-client-secret>"
# The client ID for the OIDC client
# For the best experience please ensure this is *identical* to the client_id
# you are using for Headscale. because
# client_id: "your-client-id"
# Whether to use PKCE when authenticating users. This is recommended as it
# adds an extra layer of security to the authentication process. Enabling this
# means your OIDC provider must support PKCE and it must be enabled on the
# client.
# use_pkce: true
# The client secret for the OIDC client
# You may also provide `client_secret_path` instead to read a value from disk.
# See https://headplane.net/configuration/#sensitive-values
# client_secret: "<your-client-secret>"
# If you want to disable traditional login via Headscale API keys
# disable_api_key_login: false
# Whether to use PKCE when authenticating users. This is recommended as it
# adds an extra layer of security to the authentication process. Enabling this
# means your OIDC provider must support PKCE and it must be enabled on the
# client.
# use_pkce: true
# By default profile pictures are pulled from the OIDC provider when
# we go to fetch the userinfo endpoint. Optionally, this can be set to
# "oidc" or "gravatar" as of 0.6.1.
# profile_picture_source: "gravatar"
# If you want to disable traditional login via Headscale API keys
# disable_api_key_login: false
# The scopes to request when authenticating users. The default is below.
# scope: "openid email profile"
# By default profile pictures are pulled from the OIDC provider when
# we go to fetch the userinfo endpoint. Optionally, this can be set to
# "oidc" or "gravatar" as of 0.6.1.
# profile_picture_source: "gravatar"
# Extra query parameters can be passed to the authorization endpoint
# by setting them here. This is useful for providers that require any kind
# of custom hinting.
# extra_params:
# prompt: "select_account" # Example: force account selection on Google
# The scopes to request when authenticating users. The default is below.
# scope: "openid email profile"
# Extra query parameters can be passed to the authorization endpoint
# by setting them here. This is useful for providers that require any kind
# of custom hinting.
# extra_params:
# prompt: "select_account" # Example: force account selection on Google
+137 -165
View File
@@ -5,374 +5,346 @@ All options must be under `services.headplane`.
For example: `settings.headscale.config_path` becomes `services.headplane.settings.headscale.config_path`.
## debug
*Description:* Enable debug logging
*Type:* boolean
_Description:_ Enable debug logging
*Default:* `false`
_Type:_ boolean
_Default:_ `false`
## enable
*Description:* Whether to enable headplane.
*Type:* boolean
_Description:_ Whether to enable headplane.
*Default:* `false`
_Type:_ boolean
*Example:* `true`
_Default:_ `false`
_Example:_ `true`
## package
*Description:* The headplane package to use.
*Type:* package
_Description:_ The headplane package to use.
*Default:* `pkgs.headplane`
_Type:_ package
_Default:_ `pkgs.headplane`
## settings
*Description:* Headplane configuration options. Generates a YAML config file.
_Description:_ Headplane configuration options. Generates a YAML config file.
See: https://github.com/tale/headplane/blob/main/config.example.yaml
_Type:_ submodule
*Type:* submodule
*Default:* `{ }`
_Default:_ `{ }`
## settings.headscale
*Description:* Headscale specific settings for Headplane integration.
*Type:* submodule
_Description:_ Headscale specific settings for Headplane integration.
*Default:* `{ }`
_Type:_ submodule
_Default:_ `{ }`
## settings.headscale.config_path
*Description:* Path to the Headscale configuration file.
_Description:_ Path to the Headscale configuration file.
This is optional, but HIGHLY recommended for the best experience.
If this is read only, Headplane will show your configuration settings
in the Web UI, but they cannot be changed.
_Type:_ null or absolute path
*Type:* null or absolute path
*Default:* `null`
*Example:* `"/etc/headscale/config.yaml"`
_Default:_ `null`
_Example:_ `"/etc/headscale/config.yaml"`
## settings.headscale.config_strict
*Description:* Headplane internally validates the Headscale configuration
_Description:_ Headplane internally validates the Headscale configuration
to ensure that it changes the configuration in a safe way.
If you want to disable this validation, set this to false.
_Type:_ boolean
*Type:* boolean
*Default:* `true`
_Default:_ `true`
## settings.headscale.dns_records_path
*Description:* If you are using `dns.extra_records_path` in your Headscale configuration, you need to set this to the path for Headplane to be able to read the DNS records.
_Description:_ If you are using `dns.extra_records_path` in your Headscale configuration, you need to set this to the path for Headplane to be able to read the DNS records.
Ensure that the file is both readable and writable by the Headplane process.
When using this, Headplane will no longer need to automatically restart Headscale for DNS record changes.
_Type:_ null or absolute path
*Type:* null or absolute path
*Default:* `null`
*Example:* `"/var/lib/headplane/extra_records.json"`
_Default:_ `null`
_Example:_ `"/var/lib/headplane/extra_records.json"`
## settings.headscale.public_url
*Description:* Public URL if differrent. This affects certain parts of the web UI.
*Type:* null or string
_Description:_ Public URL if differrent. This affects certain parts of the web UI.
*Default:* `null`
_Type:_ null or string
*Example:* `"https://headscale.example.com"`
_Default:_ `null`
_Example:_ `"https://headscale.example.com"`
## settings.headscale.tls_cert_path
*Description:* Path to a file containing the TLS certificate.
_Description:_ Path to a file containing the TLS certificate.
*Type:* null or absolute path
_Type:_ null or absolute path
*Default:* `null`
*Example:* `"config.sops.secrets.tls_cert.path"`
_Default:_ `null`
_Example:_ `"config.sops.secrets.tls_cert.path"`
## settings.headscale.url
*Description:* The URL to your Headscale instance.
_Description:_ The URL to your Headscale instance.
All API requests are routed through this URL.
THIS IS NOT the gRPC endpoint, but the HTTP endpoint.
IMPORTANT: If you are using TLS this MUST be set to `https://`.
_Type:_ string
*Type:* string
*Default:* `"http://127.0.0.1:8080"`
*Example:* `"https://headscale.example.com"`
_Default:_ `"http://127.0.0.1:8080"`
_Example:_ `"https://headscale.example.com"`
## settings.integration
*Description:* Integration configurations for Headplane to interact with Headscale.
*Type:* submodule
_Description:_ Integration configurations for Headplane to interact with Headscale.
*Default:* `{ }`
_Type:_ submodule
_Default:_ `{ }`
## settings.integration.agent
*Description:* Agent configuration for the Headplane agent.
*Type:* submodule
_Description:_ Agent configuration for the Headplane agent.
*Default:* `{ }`
_Type:_ submodule
_Default:_ `{ }`
## settings.integration.agent.cache_path
*Description:* Where to store the agent cache.
*Type:* absolute path
_Description:_ Where to store the agent cache.
*Default:* `"/var/lib/headplane/agent_cache.json"`
_Type:_ absolute path
_Default:_ `"/var/lib/headplane/agent_cache.json"`
## settings.integration.agent.cache_ttl
*Description:* How long to cache agent information (in milliseconds).
_Description:_ How long to cache agent information (in milliseconds).
If you want data to update faster, reduce the TTL, but this will increase the frequency of requests to Headscale.
_Type:_ signed integer
*Type:* signed integer
*Default:* `180000`
_Default:_ `180000`
## settings.integration.agent.enabled
*Description:* The Headplane agent allows retrieving information about nodes.
_Description:_ The Headplane agent allows retrieving information about nodes.
This allows the UI to display version, OS, and connectivity data.
You will see the Headplane agent in your Tailnet as a node when it connects.
_Type:_ boolean
*Type:* boolean
*Default:* `false`
_Default:_ `false`
## settings.integration.agent.host_name
*Description:* Optionally change the name of the agent in the Tailnet
*Type:* string
_Description:_ Optionally change the name of the agent in the Tailnet
*Default:* `"headplane-agent"`
_Type:_ string
_Default:_ `"headplane-agent"`
## settings.integration.agent.package
*Description:* The headplane-agent package to use.
*Type:* package
_Description:_ The headplane-agent package to use.
*Default:* `pkgs.headplane-agent`
_Type:_ package
_Default:_ `pkgs.headplane-agent`
## settings.integration.agent.pre_authkey_path
*Description:* Path to a file containing the agent preauth key.
_Description:_ Path to a file containing the agent preauth key.
To connect to your Tailnet, you need to generate a pre-auth key.
This can be done via the web UI or through the `headscale` CLI.
_Type:_ null or absolute path
*Type:* null or absolute path
*Default:* `null`
*Example:* `"config.sops.secrets.agent_pre_authkey.path"`
_Default:_ `null`
_Example:_ `"config.sops.secrets.agent_pre_authkey.path"`
## settings.integration.agent.work_dir
*Description:* Do not change this unless you are running a custom deployment.
_Description:_ Do not change this unless you are running a custom deployment.
The work_dir represents where the agent will store its data to be able to automatically reauthenticate with your Tailnet.
It needs to be writable by the user running the Headplane process.
_Type:_ absolute path
*Type:* absolute path
*Default:* `"/var/lib/headplane/agent"`
_Default:_ `"/var/lib/headplane/agent"`
## settings.integration.proc
*Description:* Native process integration settings.
*Type:* submodule
_Description:_ Native process integration settings.
*Default:* `{ }`
_Type:_ submodule
_Default:_ `{ }`
## settings.integration.proc.enabled
*Description:* Enable "Native" integration that works when Headscale and
_Description:_ Enable "Native" integration that works when Headscale and
Headplane are running outside of a container. There is no additional
configuration, but you need to ensure that the Headplane process
can terminate the Headscale process.
_Type:_ boolean
*Type:* boolean
*Default:* `true`
_Default:_ `true`
## settings.oidc
*Description:* OIDC Configuration for authentication.
*Type:* submodule
_Description:_ OIDC Configuration for authentication.
*Default:* `{ }`
_Type:_ submodule
_Default:_ `{ }`
## settings.oidc.client_id
*Description:* The client ID for the OIDC client.
*Type:* string
_Description:_ The client ID for the OIDC client.
*Default:* `""`
_Type:_ string
*Example:* `"your-client-id"`
_Default:_ `""`
_Example:_ `"your-client-id"`
## settings.oidc.client_secret_path
*Description:* Path to a file containing the OIDC client secret.
_Description:_ Path to a file containing the OIDC client secret.
*Type:* null or absolute path
_Type:_ null or absolute path
*Default:* `null`
*Example:* `"config.sops.secrets.oidc_client_secret.path"`
_Default:_ `null`
_Example:_ `"config.sops.secrets.oidc_client_secret.path"`
## settings.oidc.disable_api_key_login
*Description:* Whether to disable API key login.
*Type:* boolean
_Description:_ Whether to disable API key login.
*Default:* `false`
_Type:_ boolean
_Default:_ `false`
## settings.oidc.headscale_api_key_path
*Description:* Path to a file containing the Headscale API key.
_Description:_ Path to a file containing the Headscale API key.
*Type:* null or absolute path
_Type:_ null or absolute path
*Default:* `null`
*Example:* `"config.sops.secrets.headscale_api_key.path"`
_Default:_ `null`
_Example:_ `"config.sops.secrets.headscale_api_key.path"`
## settings.oidc.issuer
*Description:* URL to OpenID issuer.
*Type:* string
_Description:_ URL to OpenID issuer.
*Default:* `""`
_Type:_ string
*Example:* `"https://provider.example.com/issuer-url"`
_Default:_ `""`
_Example:_ `"https://provider.example.com/issuer-url"`
## settings.oidc.redirect_uri
*Description:* This should point to your publicly accessible URL
_Description:_ This should point to your publicly accessible URL
for your Headplane instance with /admin/oidc/callback.
_Type:_ string
*Type:* string
*Default:* `""`
*Example:* `"https://headscale.example.com/admin/oidc/callback"`
_Default:_ `""`
_Example:_ `"https://headscale.example.com/admin/oidc/callback"`
## settings.oidc.token_endpoint_auth_method
*Description:* The token endpoint authentication method.
*Type:* one of "client_secret_post", "client_secret_basic", "client_secret_jwt"
_Description:_ The token endpoint authentication method.
*Default:* `"client_secret_post"`
## settings.oidc.user_storage_file
*Description:* Path to a file containing the users and their permissions for Headplane.
*Type:* absolute path
*Default:* `"/var/lib/headplane/users.json"`
*Example:* `"/var/lib/headplane/users.json"`
_Type:_ one of "client_secret_post", "client_secret_basic", "client_secret_jwt"
_Default:_ `"client_secret_post"`
## settings.server
*Description:* Server configuration for Headplane web application.
*Type:* submodule
_Description:_ Server configuration for Headplane web application.
*Default:* `{ }`
_Type:_ submodule
_Default:_ `{ }`
## settings.server.cookie_secret_path
*Description:* Path to a file containing the cookie secret.
_Description:_ Path to a file containing the cookie secret.
The secret must be exactly 32 characters long.
_Type:_ null or absolute path
*Type:* null or absolute path
*Default:* `null`
*Example:* `"config.sops.secrets.headplane_cookie.path"`
_Default:_ `null`
_Example:_ `"config.sops.secrets.headplane_cookie.path"`
## settings.server.cookie_secure
*Description:* Should the cookies only work over HTTPS?
_Description:_ Should the cookies only work over HTTPS?
Set to false if running via HTTP without a proxy.
Recommended to be true in production.
_Type:_ boolean
*Type:* boolean
*Default:* `true`
_Default:_ `true`
## settings.server.data_path
*Description:* The path to persist Headplane specific data.
_Description:_ The path to persist Headplane specific data.
All data going forward is stored in this directory, including the internal database and any cache related files.
Data formats prior to 0.6.1 will automatically be migrated.
_Type:_ absolute path
*Type:* absolute path
*Default:* `"/var/lib/headplane"`
*Example:* `"/var/lib/headplane"`
_Default:_ `"/var/lib/headplane"`
_Example:_ `"/var/lib/headplane"`
## settings.server.host
*Description:* The host address to bind to.
*Type:* string
_Description:_ The host address to bind to.
*Default:* `"127.0.0.1"`
_Type:_ string
*Example:* `"0.0.0.0"`
_Default:_ `"127.0.0.1"`
_Example:_ `"0.0.0.0"`
## settings.server.port
*Description:* The port to listen on.
*Type:* 16 bit unsigned integer; between 0 and 65535 (both inclusive)
_Description:_ The port to listen on.
*Default:* `3000`
_Type:_ 16 bit unsigned integer; between 0 and 65535 (both inclusive)
_Default:_ `3000`
+157 -52
View File
@@ -13,58 +13,45 @@ outline: [2, 3]
</figure>
Single Sign-On allows users to authenticate with Headplane through an external
Identity Provider (IdP). It does this using the OpenID Connect (OIDC) protocol,
which is widely supported by many popular IdPs.
Identity Provider (IdP) using the OpenID Connect (OIDC) protocol. When enabled,
users sign in through your IdP and Headplane automatically links them to their
Headscale identity, assigns a role, and manages their session.
## Getting Started
To set up Single Sign-On (SSO) with Headplane, there are several steps involved.
As a general recommendation, please read through the entire guide before
beginning the process as there are several important factors to consider.
### Requirements
::: warning
If you are also using OpenID Connect (OIDC) authentication with Headscale, it is
**fundamentally important** that both Headscale and Headplane are configured to
use the *exact same client* in your Identity Provider (IdP). This means that
both services should share the same client ID and secret.
You'll need the following before proceeding:
This is necessary because Headplane relies on the user IDs provided by the IdP
to match users with their equivalent Headscale users. If Headscale and Headplane
are using different clients, the user IDs may not match up correctly, preventing
a user from viewing their devices in Headplane.
:::
You'll need the following things set up before proceeding:
- A working Headplane installation that is already configured.
- An Identity Provider (IdP) that supports OAuth2 and OpenID Connect (OIDC).
- `server.base_url` set to the public URL of your Headplane instance in your
configuration file (ie. the domain that's visible in the browser).
configuration file (the domain visible in the browser).
- A Headscale API key with a relatively long expiration time (eg. 1 year).
### Configuring the Client
You'll need to create a client in your Identity Provider (IdP) that Headplane
can use for authentication. A part of that step involves giving an allowed
"redirect URL" to your IdP. This URL is where the IdP will send users back to
after they have authenticated.
For Headplane, the redirect URL will be in the following format, where the
domain is replaced with the value set for `server.base_url` in your Headplane
configuration:
You'll need to create a client in your Identity Provider that Headplane can use
for authentication. As part of that step, you'll need to register a "redirect
URL" — this is where the IdP sends users after they authenticate.
For Headplane, the redirect URL will be in the following format (replace the
domain with the value set for `server.base_url`):
```
https://headplane.example.com/admin/oidc/callback
```
Once you have created the client in your IdP, make note of the following
information as you'll need it for the Headplane configuration:
Once you have created the client, make note of the following:
- Client ID
- Client Secret (if applicable)
- Issuer URL
### OIDC Configuration
To enable OIDC authentication in Headplane, you'll need to add the necessary
configuration options via the file or environment variables. See below:
To enable OIDC authentication in Headplane, add the following to your
configuration file:
```yaml
oidc:
@@ -75,18 +62,18 @@ oidc:
# You can also provide the client secret via a file:
# client_secret_path: "${HOME}/secrets/headplane_oidc_client_secret.txt"
# Those options should generally be sufficient, but you can also set these:
# These are usually auto-discovered, but can be set manually:
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# scope: "openid email profile"
# extra_params:
# foo: "bar"
# baz: "qux"
```
Headplane automatically tries to discover the necessary OIDC endpoints but if
your IdP does not support discovery, you may need to manually specify them.
Headplane automatically discovers OIDC endpoints from your issuer's
`/.well-known/openid-configuration`. If your IdP does not support discovery,
you'll need to set the endpoints manually.
### PKCE
@@ -96,29 +83,147 @@ You may need to ensure that your Identity Provider is configured to accept this
method.
:::
By default, Headplane does not use PKCE (Proof Key for Code Exchange) when
communicating with the Identity Provider. PKCE is generally a best practice for
OIDC and can enhance security. *Some Identity Providers may even require PKCE
to be used.* To enable PKCE you'll need to set `oidc.use_pkce`
to `true` in your Headplane configuration file:
By default, Headplane does not use PKCE (Proof Key for Code Exchange). PKCE is
a best practice for OIDC and enhances security — some IdPs even require it. To
enable PKCE:
```yaml
oidc:
use_pkce: true
```
## Troubleshooting
Some of the common issues you may encounter when configuring OIDC with Headplane
include:
## How User Matching Works
- **Invalid API Key**: Ensure that the API key provided to Headplane is valid
and has not expired.
- **Missing [some]_endpoint**: If your IdP does not provide standard OIDC
endpoints, you may need to manually specify them in the Headplane configuration.
- **Missing the `sub` claim**: Ensure that your IdP is configured to include the
`sub` claim in the ID token, as this is required for Headplane to identify users.
- **Redirect URI Mismatch**: Ensure that the redirect URI configured in your IdP
and that `server.base_url` in Headplane match exactly.
- **Cookie Issues**: The OIDC authentication relies on your cookie configuration
for Headplane. If OIDC cannot complete due to a missing session or invalid
session then please check your cookie settings.
When a user signs in via OIDC, Headplane needs to link them to their
corresponding Headscale user. This is important for features like showing a
user's own machines, self-service pre-auth keys, and WebSSH.
### Matching Strategy
Headplane uses a two-step matching strategy:
1. **Subject match (primary)**: Headscale stores the IdP's `provider_id` for
each OIDC user (e.g. `https://idp.example.com/3d6f6e3f-...`). Headplane
extracts the last path segment and compares it to the `sub` claim from the
OIDC token. If they match, the user is linked.
2. **Email match (fallback)**: If the subject doesn't match, Headplane falls
back to comparing the user's email address from the OIDC `userinfo` endpoint
against the email stored on the Headscale user record.
Once a link is established, it's stored as a `headscale_user_id` in Headplane's
database and reused on subsequent logins — so the matching only needs to succeed
once.
### Headscale Without OIDC
If your Headscale instance uses **local users** (created via
`headscale users create`) rather than OIDC, automatic matching cannot work —
local users have no `provider_id` or email to compare against.
In this case, Headplane will prompt the user during onboarding to manually
select which Headscale user they are. This selection is persisted, so it only
needs to happen once. After linking, all ownership-based features (viewing your
own machines, self-service pre-auth keys, WebSSH) work normally.
::: tip
If you skip the user selection during onboarding, you can still use Headplane
— you just won't have ownership-based features. An admin can manage everything
regardless of whether users are linked.
:::
### Same Client vs. Different Clients
::: tip Recommended
Using the **same OIDC client** for both Headscale and Headplane is the simplest
and most reliable setup. The `sub` claim will be identical for both services,
so subject matching always works.
:::
If your Headscale and Headplane use **different OIDC clients**, some Identity
Providers (notably Azure AD / Entra ID) may issue different `sub` values per
client application. In this case:
- Subject matching will fail on the first login.
- Headplane will fall back to email matching, which requires that the `email`
claim is available from both your IdP's `userinfo` endpoint and Headscale's
user record.
- Once the link is established, subsequent logins will work regardless because
the link is persisted.
::: warning
If you use different clients **and** your IdP does not provide an `email` claim,
Headplane will not be able to match users to their Headscale identity. Users
will still be able to sign in, but they won't be linked to a Headscale user —
meaning features like viewing their own machines or self-service pre-auth keys
won't work.
:::
## Roles and Permissions
When SSO is enabled, Headplane uses a role-based access control system to
determine what each user can do in the UI.
### Available Roles
| Role | Description |
| ----------------- | ------------------------------------------------------------------------------------------------------ |
| **Owner** | Full access to everything. Cannot be reassigned. Automatically granted to the first user who signs in. |
| **Admin** | Full access except the owner-specific flag. Can manage all users, machines, ACLs, DNS, and settings. |
| **Network Admin** | Can manage ACLs, DNS, and network settings. Can view machines and users. Can generate pre-auth keys. |
| **IT Admin** | Can manage machines, users, and feature settings. Can configure IAM. Cannot modify ACLs or DNS. |
| **Auditor** | Read-only access to everything. Can generate their own pre-auth keys. |
| **Viewer** | Can view machines and users. Can generate their own pre-auth keys. |
| **Member** | No UI access. The user exists in Headplane's database but has not been granted any permissions. |
### First Login (Owner Bootstrap)
The very first user to sign in via OIDC is automatically assigned the **Owner**
role. All subsequent users are assigned the **Member** role (no access) by
default. An owner or admin must then assign them an appropriate role through
the Users page.
### API Key Sessions
Users who sign in with a Headscale API key (instead of OIDC) are treated as
having full access. API key sessions bypass the role system entirely since
possession of the API key already implies administrative access to Headscale.
### Onboarding
When a new OIDC user signs in for the first time, they go through a brief
onboarding flow that helps them connect their first device to the Tailnet. This
flow can be skipped. Once completed, users are taken to the main dashboard.
## Troubleshooting
### Common Issues
- **"OIDC is not enabled or misconfigured"**: Check that your `oidc` section
is present in the config and that the issuer URL is reachable from the
Headplane server.
- **User signs in but can't see their machines**: The user's Headscale identity
wasn't matched. Check that either the `sub` claim matches or the `email`
claim is available (see [How User Matching Works](#how-user-matching-works)).
- **"Session cookie is empty" or login loop**: Check your `cookie_secure`
setting. If Headplane is behind a reverse proxy with HTTPS, set it to `true`.
If running without HTTPS (eg. local development), set it to `false`.
- **Invalid API Key**: The `oidc.headscale_api_key` may have expired. Generate
a new one with `headscale apikeys create --expiration 999d`.
- **Missing the `sub` claim**: Ensure your IdP includes the `sub` claim in the
ID token. This is required by the OIDC spec but some providers need explicit
configuration.
- **Redirect URI Mismatch**: Ensure the redirect URI registered in your IdP
matches `{server.base_url}/admin/oidc/callback` exactly.
- **PKCE errors**: If your IdP requires PKCE, set `oidc.use_pkce: true`. If
you see errors mentioning `code_verifier`, this is almost always the cause.
- **Missing endpoints**: If your IdP does not support OIDC discovery, you'll
need to set `authorization_endpoint`, `token_endpoint`, and
`userinfo_endpoint` manually in the config.
+60 -58
View File
@@ -5,6 +5,7 @@ outline: [2, 3]
---
# Docker Installation
::: tip
If you are not looking to deploy with Docker, follow the
[**Native Mode**](./native-mode.md) deployment guide.
@@ -15,13 +16,15 @@ easy, and works in most environments. It requires that Headscale is also running
with Docker.
## Prerequisites
- Docker and Docker Compose
- Headscale version 0.26.0 or later installed and running
- A [completed configuration file](/index.md#configuration) for Headplane.
- A [completed configuration file](./index.md#configuration) for Headplane.
## Installation
Running Headplane in with Docker is as simple as applying 1 compose file:
```yaml
services:
headplane:
@@ -29,10 +32,10 @@ services:
container_name: headplane
restart: unless-stopped
ports:
- '3000:3000'
- "3000:3000"
volumes:
- './config.yaml:/etc/headplane/config.yaml'
- './headplane-data:/var/lib/headplane'
- "./config.yaml:/etc/headplane/config.yaml"
- "./headplane-data:/var/lib/headplane"
```
It's important to mount your configuration file and also provide a persistent
@@ -87,14 +90,15 @@ Network management allows you to configure Tailnet settings such as DNS servers,
custom A records, the tailnet domain name, and MagicDNS from the Headplane UI.
#### Prerequisites
Network management (and other configurable Headscale features) requires that
Headplane and Headscale both run together in the same Docker machine. This is
because Headplane needs the following permissions:
- Access to read and write the Head**scale** configuration file through a shared
volume used by both Headscale and Headplane.
volume used by both Headscale and Headplane.
- Access to the Docker socket (usually `/var/run/docker.sock`, you may also use
a proxy such as [Tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy)).
a proxy such as [Tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy)).
#### Configuration
@@ -108,25 +112,25 @@ services:
container_name: headplane
restart: unless-stopped
ports:
- '3000:3000'
- "3000:3000"
volumes:
# Same as before
- '/path/to/your/config.yaml:/etc/headplane/config.yaml'
- '/path/to/data/storage:/var/lib/headplane'
- "/path/to/your/config.yaml:/etc/headplane/config.yaml"
- "/path/to/data/storage:/var/lib/headplane"
# A shared path to the Headscale config file. It is important that the
# path you mount this on matches `headscale.config_path` in your
# Headplane config.yaml file.
- '/path/to/headscale/config.yaml:/etc/headscale/config.yaml'
- "/path/to/headscale/config.yaml:/etc/headscale/config.yaml"
# If you are using dns.extra_records in Headscale (recommended), you
# should also mount that file here so Headplane can read and write it.
# Ensure that the path matches `headscale.dns_records_path` in your
# Headplane config.yaml file.
- '/path/to/headscale/dns_records.json:/etc/headscale/dns_records.json'
- "/path/to/headscale/dns_records.json:/etc/headscale/dns_records.json"
# Read-only access to the Docker socket (or a proxy)
- '/var/run/docker.sock:/var/run/docker.sock:ro'
- "/var/run/docker.sock:/var/run/docker.sock:ro"
headscale:
image: headscale/headscale:0.26.0
container_name: headscale
@@ -136,14 +140,14 @@ services:
# This label is absolutely necessary to help Headplane find Headscale.
me.tale.headplane.target: headscale
ports:
- '8080:8080'
- "8080:8080"
volumes:
# Notice how these are on the exact same path as the host for both
# Headscale and Headplane! This is very important.
- '/path/to/headscale/config.yaml:/etc/headscale/config.yaml'
- '/path/to/headscale/dns_records.json:/etc/headscale/dns_records.json'
- "/path/to/headscale/config.yaml:/etc/headscale/config.yaml"
- "/path/to/headscale/dns_records.json:/etc/headscale/dns_records.json"
- '/path/to/headscale/data/storage:/var/lib/headscale'
- "/path/to/headscale/data/storage:/var/lib/headscale"
```
::: info
@@ -156,11 +160,11 @@ for more details on setting it up.
You'll also need to enable a few fields in your Headplane configuration file:
| Field | Description |
|---------------------|--------------------------------------------------------|
| **`integration.docker.enabled`** | Set to `true` to enable Docker integration. |
| **`headscale.config_path`** | Path to your Head**scale** configuration file within the container (e.g., `/etc/headscale/config.yaml`). |
| `headscale.dns_records_path` | *Optional*. Refer to the [example configuration](https://github.com/tale/headplane/blob/main/config.example.yaml) for details. |
| Field | Description |
| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| **`integration.docker.enabled`** | Set to `true` to enable Docker integration. |
| **`headscale.config_path`** | Path to your Head**scale** configuration file within the container (e.g., `/etc/headscale/config.yaml`). |
| `headscale.dns_records_path` | _Optional_. Refer to the [example configuration](https://github.com/tale/headplane/blob/main/config.example.yaml) for details. |
With these settings in place, restart Headplane. You should now see additional
options in the UI navbar such as "DNS" and "Settings" where you can manage your
@@ -187,10 +191,9 @@ supports OpenID Connect (OIDC).
To get started with SSO, refer to the [SSO documentation](../features/sso.md)
for detailed setup instructions.
## Reverse Proxying
You *should* run Headplane behind a reverse proxy such as Nginx or Caddy in
You _should_ run Headplane behind a reverse proxy such as Nginx or Caddy in
production. Additionally, putting Headscale beind the reverse proxy allows
you to access both services via the same domain and TLS certificate.
@@ -222,63 +225,62 @@ services:
container_name: headplane
restart: unless-stopped
ports:
- '3000:3000'
- "3000:3000"
volumes:
- '/path/to/your/config.yaml:/etc/headplane/config.yaml'
- '/path/to/data/storage:/var/lib/headplane'
- '/path/to/headscale/config.yaml:/etc/headscale/config.yaml'
- '/path/to/headscale/dns_records.json:/etc/headscale/dns_records.json'
- '/var/run/docker.sock:/var/run/docker.sock:ro'
- "/path/to/your/config.yaml:/etc/headplane/config.yaml"
- "/path/to/data/storage:/var/lib/headplane"
- "/path/to/headscale/config.yaml:/etc/headscale/config.yaml"
- "/path/to/headscale/dns_records.json:/etc/headscale/dns_records.json"
- "/var/run/docker.sock:/var/run/docker.sock:ro"
labels:
# Expose the admin UI at /admin
- 'traefik.enable=true'
- 'traefik.http.routers.headplane.rule=Host(`headscale.example.com`) && PathPrefix(`/admin`)'
- 'traefik.http.routers.headplane.entrypoints=websecure'
- 'traefik.http.routers.headplane.tls=true'
- "traefik.enable=true"
- "traefik.http.routers.headplane.rule=Host(`headscale.example.com`) && PathPrefix(`/admin`)"
- "traefik.http.routers.headplane.entrypoints=websecure"
- "traefik.http.routers.headplane.tls=true"
headscale:
image: headscale/headscale:0.26.0
container_name: headscale
restart: unless-stopped
command: serve
ports:
- '8080:8080'
- "8080:8080"
volumes:
- '/path/to/headscale/config.yaml:/etc/headscale/config.yaml'
- '/path/to/headscale/dns_records.json:/etc/headscale/dns_records.json'
- '/path/to/headscale/data/storage:/var/lib/headscale'
- "/path/to/headscale/config.yaml:/etc/headscale/config.yaml"
- "/path/to/headscale/dns_records.json:/etc/headscale/dns_records.json"
- "/path/to/headscale/data/storage:/var/lib/headscale"
labels:
- 'me.tale.headplane.target=headscale'
- "me.tale.headplane.target=headscale"
# Traefik labels to expose Headscale at headscale.example.com
- 'traefik.enable=true'
- 'traefik.http.routers.headscale.rule=Host(`headscale.example.com`)'
- 'traefik.http.routers.headscale.entrypoints=websecure'
- 'traefik.http.routers.headscale.tls=true'
- "traefik.enable=true"
- "traefik.http.routers.headscale.rule=Host(`headscale.example.com`)"
- "traefik.http.routers.headscale.entrypoints=websecure"
- "traefik.http.routers.headscale.tls=true"
# This middleware is essential to ensuring Headplane works correctly
- 'traefik.http.routers.headscale.middlewares=cors'
- 'traefik.http.middlewares.cors.headers.accesscontrolallowheaders=*'
- 'traefik.http.middlewares.cors.headers.accesscontrolallowmethods=GET,POST,PUT'
- 'traefik.http.middlewares.cors.headers.accesscontrolalloworiginlist=https://headscale.example.com'
- 'traefik.http.middlewares.cors.headers.accesscontrolmaxage=100'
- 'traefik.http.middlewares.cors.headers.addvaryheader=true'
- "traefik.http.routers.headscale.middlewares=cors"
- "traefik.http.middlewares.cors.headers.accesscontrolallowheaders=*"
- "traefik.http.middlewares.cors.headers.accesscontrolallowmethods=GET,POST,PUT"
- "traefik.http.middlewares.cors.headers.accesscontrolalloworiginlist=https://headscale.example.com"
- "traefik.http.middlewares.cors.headers.accesscontrolmaxage=100"
- "traefik.http.middlewares.cors.headers.addvaryheader=true"
# If you would optionally like to automatically redirect / to /admin
- 'traefik.http.routers.rewrite.rule=Host(`headscale.example.com`) && Path(`/`)'
- 'traefik.http.routers.rewrite.service=headscale'
- 'traefik.http.routers.rewrite.middlewares=rewrite'
- 'traefik.http.middlewares.rewrite.addprefix.prefix=/admin'
- "traefik.http.routers.rewrite.rule=Host(`headscale.example.com`) && Path(`/`)"
- "traefik.http.routers.rewrite.service=headscale"
- "traefik.http.routers.rewrite.middlewares=rewrite"
- "traefik.http.middlewares.rewrite.addprefix.prefix=/admin"
traefik:
image: traefik:v3.0
container_name: traefik
restart: unless-stopped
ports:
- '80:80'
- '443:443'
- "80:80"
- "443:443"
volumes:
# Example volumes/setup, please configure Traefik as needed
- '/var/run/docker.sock:/var/run/docker.sock:ro'
- '/path/to/certs/storage:/certs'
- "/var/run/docker.sock:/var/run/docker.sock:ro"
- "/path/to/certs/storage:/certs"
```
+23
View File
@@ -0,0 +1,23 @@
CREATE TABLE `auth_sessions` (
`id` text PRIMARY KEY NOT NULL,
`kind` text NOT NULL,
`user_id` text,
`api_key_hash` text,
`api_key_display` text,
`expires_at` integer NOT NULL,
`created_at` integer
);
--> statement-breakpoint
ALTER TABLE `users` ADD `role` text DEFAULT 'member' NOT NULL;--> statement-breakpoint
ALTER TABLE `users` ADD `headscale_user_id` text;--> statement-breakpoint
CREATE UNIQUE INDEX `users_headscale_user_id_unique` ON `users` (`headscale_user_id`);--> statement-breakpoint
ALTER TABLE `users` ADD `created_at` integer;--> statement-breakpoint
ALTER TABLE `users` ADD `updated_at` integer;--> statement-breakpoint
ALTER TABLE `users` ADD `last_login_at` integer;--> statement-breakpoint
-- Backfill role from caps for existing users
UPDATE `users` SET `role` = 'owner' WHERE `caps` = 65535;--> statement-breakpoint
UPDATE `users` SET `role` = 'admin' WHERE `caps` = 32767;--> statement-breakpoint
UPDATE `users` SET `role` = 'network_admin' WHERE `caps` = 30015;--> statement-breakpoint
UPDATE `users` SET `role` = 'it_admin' WHERE `caps` = 8171;--> statement-breakpoint
UPDATE `users` SET `role` = 'auditor' WHERE `caps` = 66859;
+219
View File
@@ -0,0 +1,219 @@
{
"version": "6",
"dialect": "sqlite",
"id": "e397c1d9-19a4-494a-9b87-5a94a093286a",
"prevId": "2c18fbcb-d5f5-47c0-962d-54121cbb2e71",
"tables": {
"auth_sessions": {
"name": "auth_sessions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"kind": {
"name": "kind",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"api_key_hash": {
"name": "api_key_hash",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"api_key_display": {
"name": "api_key_display",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"ephemeral_nodes": {
"name": "ephemeral_nodes",
"columns": {
"auth_key": {
"name": "auth_key",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"node_key": {
"name": "node_key",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"host_info": {
"name": "host_info",
"columns": {
"host_id": {
"name": "host_id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"payload": {
"name": "payload",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"sub": {
"name": "sub",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'member'"
},
"headscale_user_id": {
"name": "headscale_user_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"onboarded": {
"name": "onboarded",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"caps": {
"name": "caps",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
}
},
"indexes": {
"users_sub_unique": {
"name": "users_sub_unique",
"columns": ["sub"],
"isUnique": true
},
"users_headscale_user_id_unique": {
"name": "users_headscale_user_id_unique",
"columns": ["headscale_user_id"],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+32 -25
View File
@@ -1,27 +1,34 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1750355487927,
"tag": "0000_spicy_bloodscream",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1755554742267,
"tag": "0001_naive_lilith",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1755617607599,
"tag": "0002_square_bloodstorm",
"breakpoints": true
}
]
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1750355487927,
"tag": "0000_spicy_bloodscream",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1755554742267,
"tag": "0001_naive_lilith",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1755617607599,
"tag": "0002_square_bloodstorm",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1772917638504,
"tag": "0003_thick_otto_octavius",
"breakpoints": true
}
]
}
Generated
+3 -3
View File
@@ -40,11 +40,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1770380644,
"narHash": "sha256-P7dWMHRUWG5m4G+06jDyThXO7kwSk46C1kgjEWcybkE=",
"lastModified": 1772173633,
"narHash": "sha256-MOH58F4AIbCkh6qlQcwMycyk5SWvsqnS/TCfnqDlpj4=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "ae67888ff7ef9dff69b3cf0cc0fbfbcd3a722abe",
"rev": "c0f3d81a7ddbc2b1332be0d8481a672b4f6004d6",
"type": "github"
},
"original": {
+1 -1
View File
@@ -55,7 +55,7 @@ in {
Group = config.services.headscale.group;
StateDirectory = "headplane";
ExecStart = "${pkgs.headplane}/bin/headplane";
ExecStart = "${cfg.package}/bin/headplane";
Restart = "always";
RestartSec = 5;
+39 -17
View File
@@ -44,6 +44,13 @@ in {
description = "The port to listen on.";
};
base_url = mkOption {
type = types.str;
default = "http://localhost:3000";
description = "The base URL for Headplane, not including the dashboard prefix (/admin) portion";
example = "http://localhost:3000";
};
cookie_secret_path = mkOption {
type = types.nullOr types.path;
default = null;
@@ -93,6 +100,13 @@ in {
'';
example = "/var/lib/headplane";
};
info_secret = mkOption {
type = types.nullOr types.str;
default = null;
description = "Secret is optional and allows access to certain debug endpoints";
example = "config.sops.secrets.info_secret.path";
};
};
};
default = {};
@@ -267,6 +281,27 @@ in {
example = "https://provider.example.com/issuer-url";
};
authorization_endpoint = mkOption {
type = types.nullOr types.str;
default = null;
description = "Optionally override authorization_endpoint";
example = "https://provider.example.com/authorization_endpoint";
};
token_endpoint = mkOption {
type = types.nullOr types.str;
default = null;
description = "Optionally override token_endpoint";
example = "https://provider.example.com/token_endpoint";
};
userinfo_endpoint = mkOption {
type = types.nullOr types.str;
default = null;
description = "Optionally override userinfo_endpoint";
example = "https://provider.example.com/userinfo_endpoint";
};
client_id = mkOption {
type = types.str;
default = "";
@@ -308,23 +343,10 @@ in {
example = "config.sops.secrets.headscale_api_key.path";
};
redirect_uri = mkOption {
type = types.str;
default = "";
description = ''
This should point to your publicly accessible URL
for your Headplane instance with /admin/oidc/callback.
'';
example = "https://headscale.example.com/admin/oidc/callback";
};
user_storage_file = mkOption {
type = types.path;
default = "/var/lib/headplane/users.json";
description = ''
Path to a file containing the users and their permissions for Headplane.
'';
example = "/var/lib/headplane/users.json";
use_pkce = mkOption {
type = types.bool;
default = false;
description = "Whether to use PKCE when authenticating users.";
};
};
};
+1 -1
View File
@@ -28,7 +28,7 @@ in
pnpmDeps = pnpm_10.fetchDeps {
inherit (finalAttrs) pname version src;
hash = "sha256-z51OBxoJ5SrC8ctfk4z+tUtiSQzBq4iiJlngfK60cqo=";
hash = "sha256-Xtooqpibv4fuJczUfJDlGt2+5KuoKq/TUUhLKE+ierA=";
fetcherVersion = 1;
};
+35 -35
View File
@@ -1,6 +1,6 @@
{
"name": "headplane",
"version": "0.6.1",
"version": "0.6.2",
"private": true,
"type": "module",
"sideEffects": false,
@@ -27,25 +27,25 @@
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@faker-js/faker": "10.2.0",
"@faker-js/faker": "10.3.0",
"@fontsource-variable/inter": "^5.2.8",
"@iconify/react": "^6.0.2",
"@kubernetes/client-node": "^1.4.0",
"@react-aria/toast": "3.0.9",
"@react-router/dev": "^7.12.0",
"@react-router/node": "^7.12.0",
"@react-stately/toast": "3.1.2",
"@react-aria/toast": "3.0.10",
"@react-router/dev": "^7.13.1",
"@react-router/node": "^7.13.1",
"@react-stately/toast": "3.1.3",
"@readme/openapi-parser": "^5.5.0",
"@shopify/lang-jsonc": "^1.0.1",
"@tailwindcss/vite": "^4.1.18",
"@tailwindcss/vite": "^4.2.1",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.0.9",
"@types/react": "^19.2.8",
"@types/node": "^25.3.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260115.1",
"@typescript/native-preview": "7.0.0-dev.20260225.1",
"@uiw/codemirror-theme-github": "4.25.1",
"@uiw/codemirror-theme-xcode": "4.25.4",
"@uiw/react-codemirror": "4.25.4",
"@uiw/codemirror-theme-xcode": "4.25.5",
"@uiw/react-codemirror": "4.25.5",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
@@ -53,44 +53,44 @@
"@xterm/xterm": "^6.0.0",
"arktype": "^2.1.29",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.8",
"drizzle-kit": "^0.31.9",
"drizzle-orm": "0.45.1",
"isbot": "5.1.32",
"isbot": "5.1.35",
"jose": "6.1.3",
"js-yaml": "^4.1.1",
"lefthook": "^2.0.15",
"lucide-react": "^0.562.0",
"lefthook": "^2.1.1",
"lucide-react": "^0.575.0",
"mime": "^4.1.0",
"openapi-types": "^12.1.3",
"openid-client": "6.8.1",
"oxfmt": "^0.24.0",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.1",
"openid-client": "6.8.2",
"oxfmt": "^0.35.0",
"oxlint": "^1.50.0",
"oxlint-tsgolint": "^0.15.0",
"postcss": "^8.5.6",
"react": "19.2.3",
"react-aria": "3.45.0",
"react-codemirror-merge": "4.25.4",
"react-dom": "19.2.3",
"react-error-boundary": "^6.1.0",
"react-router": "^7.12.0",
"react-router-dom": "^7.12.0",
"react-router-hono-server": "2.23.0",
"react-stately": "3.43.0",
"remix-utils": "^9.0.0",
"tailwind-merge": "3.4.0",
"tailwindcss": "^4.1.18",
"react": "19.2.4",
"react-aria": "3.46.0",
"react-codemirror-merge": "4.25.5",
"react-dom": "19.2.4",
"react-error-boundary": "^6.1.1",
"react-router": "^7.13.1",
"react-router-dom": "^7.13.1",
"react-router-hono-server": "2.25.0",
"react-stately": "3.44.0",
"remix-utils": "^9.0.1",
"tailwind-merge": "3.5.0",
"tailwindcss": "^4.2.1",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-react-aria-components": "^2.0.1",
"testcontainers": "^11.11.0",
"testcontainers": "^11.12.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"ulidx": "2.4.1",
"undici": "7.18.2",
"undici": "7.22.0",
"usehooks-ts": "^3.1.1",
"vite": "8.0.0-beta.0",
"vite-tsconfig-paths": "^6.0.4",
"vitepress": "next",
"vitest": "^4.0.17",
"vitest": "^4.0.18",
"yaml": "2.8.2"
},
"engines": {
+2527 -1986
View File
File diff suppressed because it is too large Load Diff
+61 -3
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { getRuntimeClient, HS_VERSIONS } from "./setup/env";
import { getBootstrapClient, getIsAtLeast, getRuntimeClient, HS_VERSIONS } from "./setup/env";
describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => {
test("pre-auth keys can be created", async () => {
@@ -13,7 +13,7 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, false, false, expiry, null);
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.ephemeral).toBe(false);
expect(preAuthKey.reusable).toBe(false);
expect(preAuthKey.aclTags).toEqual([]);
@@ -30,12 +30,29 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, true, true, null, aclTags);
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.user?.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.ephemeral).toBe(true);
expect(preAuthKey.reusable).toBe(true);
expect(preAuthKey.aclTags.sort()).toEqual(aclTags.sort());
});
test("tag-only pre-auth keys (0.28+)", async (context) => {
const bootstrap = await getBootstrapClient(version);
if (!bootstrap.clientHelpers.isAtleast("0.28.0")) {
context.skip();
}
const client = await getRuntimeClient(version);
const aclTags = ["tag:server", "tag:prod"];
const preAuthKey = await client.createPreAuthKey(null, false, true, null, aclTags);
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user).toBeNull();
expect(preAuthKey.ephemeral).toBe(false);
expect(preAuthKey.reusable).toBe(true);
expect(preAuthKey.aclTags.sort()).toEqual(aclTags.sort());
});
test("pre-auth keys can be listed", async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
@@ -47,6 +64,47 @@ describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) =>
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
});
test("all pre-auth keys can be listed without user filter (0.28+)", async (context) => {
const isAtLeast = await getIsAtLeast(version);
if (!isAtLeast("0.28.0")) {
context.skip();
}
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
expect(preAuthKeyUser).toBeDefined();
const allKeys = await client.getAllPreAuthKeys();
expect(Array.isArray(allKeys)).toBe(true);
expect(allKeys.length).toBeGreaterThanOrEqual(2);
const userSpecificKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
for (const userKey of userSpecificKeys) {
const found = allKeys.find((k) => k.key === userKey.key);
expect(found).toBeDefined();
}
});
test("getAllPreAuthKeys returns keys with correct structure (0.28+)", async (context) => {
const isAtLeast = await getIsAtLeast(version);
if (!isAtLeast("0.28.0")) {
context.skip();
}
const client = await getRuntimeClient(version);
const allKeys = await client.getAllPreAuthKeys();
for (const key of allKeys) {
expect(key.id).toBeDefined();
expect(key.key).toBeDefined();
expect(typeof key.reusable).toBe("boolean");
expect(typeof key.ephemeral).toBe("boolean");
expect(typeof key.used).toBe("boolean");
expect(key.expiration).toBeDefined();
expect(key.createdAt).toBeDefined();
}
});
test("pre-auth keys can be expired", async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
+192
View File
@@ -0,0 +1,192 @@
import { describe, expect, test, vi } from "vitest";
// Helper to create a mock FormData with optional api_key
function mockFormData(apiKey?: string): FormData {
const formData = new FormData();
if (apiKey !== undefined) {
formData.set("api_key", apiKey);
}
return formData;
}
// Helper to create mock request
function mockRequest(formData: FormData): Request {
return {
formData: () => Promise.resolve(formData),
} as unknown as Request;
}
// Types for test clarity
interface LoginResult {
success: boolean;
message: string;
}
interface MockApiKey {
prefix: string;
expiration: string | null;
}
// Mock the log module to avoid console spam during tests
vi.mock("~/utils/log", () => ({
default: {
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
},
}));
describe("Login action validation", () => {
test("returns error when api_key field is missing", async () => {
const { loginAction } = await import("~/routes/auth/login/action");
const formData = mockFormData(); // no api_key
const request = mockRequest(formData);
const mockContext = {
hsApi: { getRuntimeClient: vi.fn() },
sessions: { createSession: vi.fn() },
};
const result = (await loginAction({
request,
context: mockContext,
params: {},
} as any)) as LoginResult;
expect(result.success).toBe(false);
expect(result.message).toContain("Missing");
});
test("returns error when api_key is empty string", async () => {
const { loginAction } = await import("~/routes/auth/login/action");
const formData = mockFormData("");
const request = mockRequest(formData);
const mockContext = {
hsApi: { getRuntimeClient: vi.fn() },
sessions: { createSession: vi.fn() },
};
const result = (await loginAction({
request,
context: mockContext,
params: {},
} as any)) as LoginResult;
expect(result.success).toBe(false);
expect(result.message).toContain("empty");
});
test("returns error when api key not found in database", async () => {
const { loginAction } = await import("~/routes/auth/login/action");
const formData = mockFormData("some-invalid-key-12345");
const request = mockRequest(formData);
const mockGetApiKeys = vi
.fn()
.mockResolvedValue([{ prefix: "other-prefix", expiration: "2030-01-01T00:00:00Z" }]);
const mockContext = {
hsApi: {
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
},
sessions: { createSession: vi.fn() },
};
const result = (await loginAction({
request,
context: mockContext,
params: {},
} as any)) as LoginResult;
expect(result.success).toBe(false);
expect(result.message).toContain("not found");
});
test("returns error when api key has expired", async () => {
const { loginAction } = await import("~/routes/auth/login/action");
const expiredKey = "expired-key-prefix.secret";
const formData = mockFormData(expiredKey);
const request = mockRequest(formData);
const mockGetApiKeys = vi
.fn()
.mockResolvedValue([{ prefix: "expired-key-prefix", expiration: "2020-01-01T00:00:00Z" }]);
const mockContext = {
hsApi: {
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
},
sessions: { createSession: vi.fn() },
};
const result = (await loginAction({
request,
context: mockContext,
params: {},
} as any)) as LoginResult;
expect(result.success).toBe(false);
expect(result.message).toContain("expired");
});
test("returns error when api key has no expiration field", async () => {
const { loginAction } = await import("~/routes/auth/login/action");
const keyWithoutExpiry = "malformed-key.secret";
const formData = mockFormData(keyWithoutExpiry);
const request = mockRequest(formData);
const mockGetApiKeys = vi
.fn()
.mockResolvedValue([{ prefix: "malformed-key", expiration: null } as MockApiKey]);
const mockContext = {
hsApi: {
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
},
sessions: { createSession: vi.fn() },
};
const result = (await loginAction({
request,
context: mockContext,
params: {},
} as any)) as LoginResult;
expect(result.success).toBe(false);
expect(result.message).toContain("malformed");
});
test("handles asterisks in api key prefix from headscale 0.28+", async () => {
const { loginAction } = await import("~/routes/auth/login/action");
const apiKey = "my-key-prefix.the-secret-part";
const formData = mockFormData(apiKey);
const request = mockRequest(formData);
const futureDate = new Date();
futureDate.setFullYear(futureDate.getFullYear() + 1);
const mockGetApiKeys = vi
.fn()
.mockResolvedValue([{ prefix: "my-***-prefix", expiration: futureDate.toISOString() }]);
const mockCreateSession = vi.fn().mockResolvedValue("session-cookie");
const mockContext = {
hsApi: {
getRuntimeClient: () => ({ getApiKeys: mockGetApiKeys }),
},
sessions: { createSession: mockCreateSession },
};
const result = await loginAction({
request,
context: mockContext,
params: {},
} as any);
// Should match despite asterisks in stored prefix
// Result will be a redirect (Response) on success, not our error object
expect(result).toBeDefined();
});
});
+125 -69
View File
@@ -1,81 +1,137 @@
import { dump } from 'js-yaml';
import { beforeAll, describe, expect, test } from 'vitest';
import { ConfigError } from '~/server/config/error';
import { loadConfig, loadConfigFile } from '~/server/config/load';
import { clearFakeFiles, createFakeFile } from '../setup/overlay-fs';
import { dump } from "js-yaml";
import { beforeAll, describe, expect, test } from "vitest";
import { ConfigError } from "~/server/config/error";
import { loadConfig, loadConfigFile } from "~/server/config/load";
import { clearFakeFiles, createFakeFile } from "../setup/overlay-fs";
const writeYaml = (filePath: string, content: unknown) => {
const yamlContent = dump(content);
createFakeFile(filePath, yamlContent);
const yamlContent = dump(content);
createFakeFile(filePath, yamlContent);
};
describe('Configuration YAML file loading', () => {
beforeAll(() => {
clearFakeFiles();
});
describe("Configuration YAML file loading", () => {
beforeAll(() => {
clearFakeFiles();
});
test('should correctly parse different types from YAML file', async () => {
const filePath = '/config/test-config.yaml';
writeYaml(filePath, {
headscale: {
url: 'http://localhost:8080',
},
oidc: {
client_id: 'my-client-id',
},
server: {
port: 8000,
},
integration: {
agent: {
enabled: true,
},
},
});
test("should correctly parse different types from YAML file", async () => {
const filePath = "/config/test-config.yaml";
writeYaml(filePath, {
headscale: {
url: "http://localhost:8080",
},
oidc: {
client_id: "my-client-id",
},
server: {
port: 8000,
},
integration: {
agent: {
enabled: true,
},
},
});
const config = await loadConfigFile(filePath);
expect(config?.headscale?.url).toBe('http://localhost:8080');
expect(config?.oidc?.client_id).toBe('my-client-id');
expect(config?.server?.port).toBe(8000);
expect(config?.integration?.agent?.enabled).toBe(true);
});
const config = await loadConfigFile(filePath);
expect(config?.headscale?.url).toBe("http://localhost:8080");
expect(config?.oidc?.client_id).toBe("my-client-id");
expect(config?.server?.port).toBe(8000);
expect(config?.integration?.agent?.enabled).toBe(true);
});
test('should not throw errors for inaccessible file', async () => {
await expect(
loadConfigFile('/non-existent-path/config.yaml'),
).resolves.toBeUndefined();
});
test("should not throw errors for inaccessible file", async () => {
await expect(loadConfigFile("/non-existent-path/config.yaml")).resolves.toBeUndefined();
});
test('should correctly get a finalized config from YAML', async () => {
const filePath = '/config/minimal-config.yaml';
writeYaml(filePath, {
headscale: {
url: 'http://localhost:8080',
},
server: {
cookie_secret: 'thirtytwo-character-cookiesecret',
},
});
test("should correctly get a finalized config from YAML", async () => {
const filePath = "/config/minimal-config.yaml";
writeYaml(filePath, {
headscale: {
url: "http://localhost:8080",
},
server: {
cookie_secret: "thirtytwo-character-cookiesecret",
},
});
const config = await loadConfig(filePath);
expect(config.headscale.url).toBe('http://localhost:8080');
expect(config.server.cookie_secret).toBe(
'thirtytwo-character-cookiesecret',
);
});
const config = await loadConfig(filePath);
expect(config.headscale.url).toBe("http://localhost:8080");
expect(config.server.cookie_secret).toBe("thirtytwo-character-cookiesecret");
});
test('should throw error for missing required fields', async () => {
const filePath = '/config/invalid-config.yaml';
writeYaml(filePath, {
server: {
port: 8000,
},
});
test("should throw error for missing required fields", async () => {
const filePath = "/config/invalid-config.yaml";
writeYaml(filePath, {
server: {
port: 8000,
},
});
await expect(loadConfig(filePath)).rejects.toEqual(
expect.objectContaining(
ConfigError.from('INVALID_REQUIRED_FIELDS', { messages: [] }),
),
);
});
await expect(loadConfig(filePath)).rejects.toEqual(
expect.objectContaining(ConfigError.from("INVALID_REQUIRED_FIELDS", { messages: [] })),
);
});
test("oidc.enabled defaults to true when oidc section is present", async () => {
const filePath = "/config/oidc-default-enabled.yaml";
writeYaml(filePath, {
headscale: { url: "http://localhost:8080" },
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
oidc: {
issuer: "https://accounts.google.com",
client_id: "my-client-id",
client_secret: "my-client-secret",
headscale_api_key: "my-api-key",
},
});
const config = await loadConfig(filePath);
expect(config.oidc).toBeDefined();
expect(config.oidc?.enabled).toBe(true);
});
test("oidc.enabled can be set to false to disable OIDC", async () => {
const filePath = "/config/oidc-disabled.yaml";
writeYaml(filePath, {
headscale: { url: "http://localhost:8080" },
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
oidc: {
enabled: false,
issuer: "https://accounts.google.com",
client_id: "my-client-id",
client_secret: "my-client-secret",
headscale_api_key: "my-api-key",
},
});
const config = await loadConfig(filePath);
expect(config.oidc).toBeDefined();
expect(config.oidc?.enabled).toBe(false);
});
test("partial oidc config with enabled field can be parsed", async () => {
const filePath = "/config/oidc-partial.yaml";
writeYaml(filePath, {
headscale: { url: "http://localhost:8080" },
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
oidc: { enabled: false },
});
const partialConfig = await loadConfigFile(filePath);
expect(partialConfig?.oidc?.enabled).toBe(false);
});
test("config without oidc section has undefined oidc", async () => {
const filePath = "/config/no-oidc.yaml";
writeYaml(filePath, {
headscale: { url: "http://localhost:8080" },
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
});
const config = await loadConfig(filePath);
expect(config.oidc).toBeUndefined();
});
});
+74 -43
View File
@@ -1,54 +1,85 @@
import { beforeEach, describe, expect, test } from 'vitest';
import { ConfigError } from '~/server/config/error';
import { loadConfig, loadConfigEnv } from '~/server/config/load';
import { beforeEach, describe, expect, test } from "vitest";
import { ConfigError } from "~/server/config/error";
import { loadConfig, loadConfigEnv } from "~/server/config/load";
const envVarSnapshot = { ...process.env };
describe('Configuration environment variable handling', () => {
beforeEach(() => {
process.env = { ...envVarSnapshot };
});
describe("Configuration environment variable handling", () => {
beforeEach(() => {
process.env = { ...envVarSnapshot };
});
test('should correctly parse different types from env vars', async () => {
process.env.HEADPLANE_HEADSCALE__URL = 'http://localhost:8080';
process.env.HEADPLANE_OIDC__CLIENT_ID = 'my-client-id';
process.env.HEADPLANE_SERVER__PORT = '8000';
process.env.HEADPLANE_INTEGRATION__AGENT__ENABLED = 'true';
test("should correctly parse different types from env vars", async () => {
process.env.HEADPLANE_HEADSCALE__URL = "http://localhost:8080";
process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id";
process.env.HEADPLANE_SERVER__PORT = "8000";
process.env.HEADPLANE_INTEGRATION__AGENT__ENABLED = "true";
const config = await loadConfigEnv();
expect(config?.headscale?.url).toBe('http://localhost:8080');
expect(config?.oidc?.client_id).toBe('my-client-id');
expect(config?.server?.port).toBe(8000);
expect(config?.integration?.agent?.enabled).toBe(true);
});
const config = await loadConfigEnv();
expect(config?.headscale?.url).toBe("http://localhost:8080");
expect(config?.oidc?.client_id).toBe("my-client-id");
expect(config?.server?.port).toBe(8000);
expect(config?.integration?.agent?.enabled).toBe(true);
});
test('should not load env vars without the HEADPLANE_ prefix', async () => {
process.env.HEADPLANE_HEADSCALE__URL = 'http://localhost:8080';
process.env.OTHER_PREFIX_OIDC__CLIENT_ID = 'should-not-be-loaded';
test("should not load env vars without the HEADPLANE_ prefix", async () => {
process.env.HEADPLANE_HEADSCALE__URL = "http://localhost:8080";
process.env.OTHER_PREFIX_OIDC__CLIENT_ID = "should-not-be-loaded";
const config = await loadConfigEnv();
expect(config?.headscale?.url).toBe('http://localhost:8080');
expect(config?.oidc?.client_id).toBeUndefined();
});
const config = await loadConfigEnv();
expect(config?.headscale?.url).toBe("http://localhost:8080");
expect(config?.oidc?.client_id).toBeUndefined();
});
test('should correctly get a finalized config from env vars', async () => {
process.env.HEADPLANE_HEADSCALE__URL = 'http://localhost:8080';
process.env.HEADPLANE_SERVER__COOKIE_SECRET =
'thirtytwo-character-cookiesecret';
test("should correctly get a finalized config from env vars", async () => {
process.env.HEADPLANE_HEADSCALE__URL = "http://localhost:8080";
process.env.HEADPLANE_SERVER__COOKIE_SECRET = "thirtytwo-character-cookiesecret";
const config = await loadConfig('./non-existent-path.yaml');
expect(config.headscale.url).toBe('http://localhost:8080');
expect(config.server.cookie_secret).toBe(
'thirtytwo-character-cookiesecret',
);
});
const config = await loadConfig("./non-existent-path.yaml");
expect(config.headscale.url).toBe("http://localhost:8080");
expect(config.server.cookie_secret).toBe("thirtytwo-character-cookiesecret");
});
test('should throw error for missing required fields', async () => {
process.env.HEADPLANE_SERVER__PORT = '8000';
test("should throw error for missing required fields", async () => {
process.env.HEADPLANE_SERVER__PORT = "8000";
await expect(loadConfig('./non-existent-path.yaml')).rejects.toEqual(
expect.objectContaining(
ConfigError.from('INVALID_REQUIRED_FIELDS', { messages: [] }),
),
);
});
await expect(loadConfig("./non-existent-path.yaml")).rejects.toEqual(
expect.objectContaining(ConfigError.from("INVALID_REQUIRED_FIELDS", { messages: [] })),
);
});
test("oidc.enabled can be set via HEADPLANE_OIDC__ENABLED env var", async () => {
process.env.HEADPLANE_OIDC__ENABLED = "true";
process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com";
process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id";
const config = await loadConfigEnv();
expect(config?.oidc?.enabled).toBe(true);
expect(config?.oidc?.issuer).toBe("https://accounts.google.com");
});
test("oidc.enabled=false can be set via env var", async () => {
process.env.HEADPLANE_OIDC__ENABLED = "false";
process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com";
process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id";
const config = await loadConfigEnv();
expect(config?.oidc?.enabled).toBe(false);
});
test("oidc.enabled=false via env var preserves full OIDC config", async () => {
process.env.HEADPLANE_HEADSCALE__URL = "http://localhost:8080";
process.env.HEADPLANE_SERVER__COOKIE_SECRET = "thirtytwo-character-cookiesecret";
process.env.HEADPLANE_OIDC__ENABLED = "false";
process.env.HEADPLANE_OIDC__ISSUER = "https://accounts.google.com";
process.env.HEADPLANE_OIDC__CLIENT_ID = "my-client-id";
process.env.HEADPLANE_OIDC__CLIENT_SECRET = "my-client-secret";
process.env.HEADPLANE_OIDC__HEADSCALE_API_KEY = "my-api-key";
const config = await loadConfig("./non-existent-path.yaml");
expect(config.oidc).toBeDefined();
expect(config.oidc?.enabled).toBe(false);
expect(config.oidc?.issuer).toBe("https://accounts.google.com");
expect(config.oidc?.client_id).toBe("my-client-id");
});
});
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, test } from "vitest";
import { Capabilities, hasCapability, Roles, getRoleFromCapabilities } from "~/server/web/roles";
describe("Roles and Capabilities", () => {
describe("Roles definitions", () => {
test("owner has all capabilities including ui_access", () => {
expect(Roles.owner & Capabilities.ui_access).toBe(Capabilities.ui_access);
expect(Roles.owner & Capabilities.owner).toBe(Capabilities.owner);
expect(Roles.owner & Capabilities.write_users).toBe(Capabilities.write_users);
});
test("admin has ui_access but not owner flag", () => {
expect(Roles.admin & Capabilities.ui_access).toBe(Capabilities.ui_access);
expect(Roles.admin & Capabilities.owner).toBe(0);
expect(Roles.admin & Capabilities.write_users).toBe(Capabilities.write_users);
});
test("auditor has ui_access but limited write permissions", () => {
expect(Roles.auditor & Capabilities.ui_access).toBe(Capabilities.ui_access);
expect(Roles.auditor & Capabilities.write_users).toBe(0);
expect(Roles.auditor & Capabilities.read_users).toBe(Capabilities.read_users);
});
test("member has NO capabilities (including no ui_access)", () => {
expect(Roles.member).toBe(0);
expect(Roles.member & Capabilities.ui_access).toBe(0);
expect(Roles.member & Capabilities.read_machines).toBe(0);
});
});
describe("hasCapability function", () => {
test("returns true when role has the capability", () => {
expect(hasCapability("owner", "ui_access")).toBe(true);
expect(hasCapability("admin", "ui_access")).toBe(true);
expect(hasCapability("auditor", "ui_access")).toBe(true);
});
test("returns false when role lacks the capability", () => {
expect(hasCapability("member", "ui_access")).toBe(false);
expect(hasCapability("auditor", "write_users")).toBe(false);
});
test("only owner has owner capability", () => {
expect(hasCapability("owner", "owner")).toBe(true);
expect(hasCapability("admin", "owner")).toBe(false);
expect(hasCapability("member", "owner")).toBe(false);
});
});
describe("getRoleFromCapabilities function", () => {
test("returns correct role for exact capability match", () => {
expect(getRoleFromCapabilities(Roles.owner)).toBe("owner");
expect(getRoleFromCapabilities(Roles.admin)).toBe("admin");
expect(getRoleFromCapabilities(Roles.auditor)).toBe("auditor");
expect(getRoleFromCapabilities(Roles.member)).toBe("member");
});
test("returns member for unrecognized capability values", () => {
expect(getRoleFromCapabilities(999999 as any)).toBe("member");
});
});
describe("member role", () => {
test("blocks UI access", () => {
const memberCaps = Roles.member;
const hasUIAccess = (memberCaps & Capabilities.ui_access) === Capabilities.ui_access;
expect(hasUIAccess).toBe(false);
expect(memberCaps).toBe(0);
});
test("other roles have UI access", () => {
const rolesWithUIAccess = ["owner", "admin", "network_admin", "it_admin", "auditor"] as const;
for (const role of rolesWithUIAccess) {
expect(hasCapability(role, "ui_access")).toBe(true);
}
});
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, test } from "vitest";
import type { User } from "~/types/User";
import { getUserDisplayName } from "~/utils/user";
const makeUser = (overrides: Partial<User>): User => ({
id: "default-id",
name: "",
createdAt: "2024-01-01T00:00:00Z",
...overrides,
});
describe("getUserDisplayName", () => {
test("uses name when set", () => {
const user = makeUser({ id: "123", name: "John" });
expect(getUserDisplayName(user)).toBe("John");
});
test("uses displayName when name is empty", () => {
const user = makeUser({ id: "123", name: "", displayName: "John Doe" });
expect(getUserDisplayName(user)).toBe("John Doe");
});
test("uses email when name and displayName are empty", () => {
const user = makeUser({ id: "123", name: "", displayName: "", email: "john@example.com" });
expect(getUserDisplayName(user)).toBe("john@example.com");
});
test("uses id when everything else is empty", () => {
const user = makeUser({ id: "123", name: "", displayName: "", email: "" });
expect(getUserDisplayName(user)).toBe("123");
});
test("uses id when optional fields are undefined", () => {
const user = makeUser({ id: "123", name: "", displayName: undefined, email: undefined });
expect(getUserDisplayName(user)).toBe("123");
});
test("prefers name over displayName", () => {
const user = makeUser({
id: "123",
name: "John",
displayName: "John Doe",
email: "john@example.com",
});
expect(getUserDisplayName(user)).toBe("John");
});
test("prefers displayName over email", () => {
const user = makeUser({
id: "123",
name: "",
displayName: "John Doe",
email: "john@example.com",
});
expect(getUserDisplayName(user)).toBe("John Doe");
});
});
+66 -54
View File
@@ -1,65 +1,77 @@
import { readFile } from 'node:fs/promises';
import { reactRouter } from '@react-router/dev/vite';
import tailwindcss from '@tailwindcss/vite';
import { reactRouterHonoServer } from 'react-router-hono-server/dev';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
import { parse } from 'yaml';
import { execSync } from "node:child_process";
import { readFile } from "node:fs/promises";
const prefix = process.env.__INTERNAL_PREFIX || '/admin';
if (prefix.endsWith('/')) {
throw new Error('Prefix must not end with a slash');
import { reactRouter } from "@react-router/dev/vite";
import tailwindcss from "@tailwindcss/vite";
import { reactRouterHonoServer } from "react-router-hono-server/dev";
import { defineConfig } from "vite";
import tsconfigPaths from "vite-tsconfig-paths";
import { parse } from "yaml";
const prefix = process.env.__INTERNAL_PREFIX || "/admin";
if (prefix.endsWith("/")) {
throw new Error("Prefix must not end with a slash");
}
// Load the version via package.json
const pkg = await readFile('package.json', 'utf-8');
const isNext = process.env.IMAGE_TAG?.includes('next');
const { version } = JSON.parse(pkg);
// Derive version: HEADPLANE_VERSION env > git describe > package.json
const isNext = process.env.IMAGE_TAG?.includes("next");
let version: string;
if (process.env.HEADPLANE_VERSION) {
version = process.env.HEADPLANE_VERSION;
} else {
try {
const describe = execSync("git describe --tags", { encoding: "utf-8" })
.trim()
.replace(/^v/, "");
const tag = execSync("git describe --tags --abbrev=0", { encoding: "utf-8" })
.trim()
.replace(/^v/, "");
version = describe === tag ? tag : `${tag}-dev+${describe.split("-").pop()}`;
} catch {
const pkg = await readFile("package.json", "utf-8");
version = JSON.parse(pkg).version;
}
}
if (!version) {
throw new Error('Unable to read version from package.json');
throw new Error("Unable to determine version");
}
// Load the config without any environment variables (not needed here)
const config = await readFile('config.example.yaml', 'utf-8');
const config = await readFile("config.example.yaml", "utf-8");
const { server } = parse(config);
export default defineConfig(({ command, isSsrBuild }) => ({
base: command === 'build' ? `${prefix}/` : undefined,
plugins: [
reactRouterHonoServer(),
reactRouter(),
tailwindcss(),
tsconfigPaths(),
],
server: {
host: server.host,
port: server.port,
},
build: {
target: 'esnext',
sourcemap: true,
rolldownOptions:
command === 'build'
? {
// Exclude libsql from the server side since it's a native module
// Exclude WASM from the client since it fetches from the server
external: isSsrBuild ? [/^@libsql\//] : [/\.wasm(\?url)?$/],
output: {
manualChunks: undefined,
inlineDynamicImports: isSsrBuild,
},
}
: undefined,
},
ssr: {
target: 'node',
noExternal: command === 'build' ? true : undefined,
},
optimizeDeps: {
include: ['@libsql/client'],
},
define: {
__VERSION__: JSON.stringify(isNext ? `${version}-next` : version),
__PREFIX__: JSON.stringify(prefix),
},
base: command === "build" ? `${prefix}/` : undefined,
plugins: [reactRouterHonoServer(), reactRouter(), tailwindcss(), tsconfigPaths()],
server: {
host: server.host,
port: server.port,
},
build: {
target: "esnext",
sourcemap: true,
rolldownOptions:
command === "build"
? {
// Exclude libsql from the server side since it's a native module
// Exclude WASM from the client since it fetches from the server
external: isSsrBuild ? [/^@libsql\//] : [/\.wasm(\?url)?$/],
output: {
manualChunks: undefined,
inlineDynamicImports: isSsrBuild,
},
}
: undefined,
},
ssr: {
target: "node",
noExternal: command === "build" ? true : undefined,
},
optimizeDeps: {
include: ["@libsql/client"],
},
define: {
__VERSION__: JSON.stringify(isNext ? `${version}-next` : version),
__PREFIX__: JSON.stringify(prefix),
},
}));