mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 08:08:56 +00:00
Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a967731461 | |||
| a1efe36ff1 | |||
| 270b99f063 | |||
| 434f886034 | |||
| 684a95b5e8 | |||
| 45984ec639 | |||
| 0f8e192b5c | |||
| 6d70497758 | |||
| f4a2811afb | |||
| 5f2cbebbcd | |||
| da3132323c | |||
| cb97b73abd | |||
| 432cd29cd0 | |||
| 1feb109a36 | |||
| f6613f1faf | |||
| 210d473bc2 | |||
| 67b1611d13 | |||
| df11aefd0c | |||
| f9dc9d454b | |||
| 64edd2041b | |||
| 1462016a39 | |||
| 779103147d | |||
| be79413178 | |||
| 787f8decbc | |||
| 0cff389051 | |||
| 3c5a653942 | |||
| 2e5bb791e8 | |||
| ab3c4f7819 | |||
| b2db6efd63 | |||
| 937bc4a667 | |||
| 9f4a7dc6e0 | |||
| 2afc11f652 | |||
| 624b9ab479 | |||
| dde72ae4ed | |||
| 20c2a71713 | |||
| 2fdbf4abea | |||
| 5e0ac88189 | |||
| 4cb231deb1 | |||
| 2a66e02bd4 | |||
| a385c72d90 | |||
| 4f59f4c77b | |||
| 20297c6800 | |||
| 7c39f8f3a8 | |||
| 2e6b387d67 | |||
| 5e45295523 | |||
| a6b25b3ba3 | |||
| badefc7f85 | |||
| 30dd718d68 | |||
| c96249f41e | |||
| c60a47b968 | |||
| 026b683c72 | |||
| 23df48bb2f | |||
| 9183ec2942 | |||
| e70319ae1a | |||
| 0b2ea70f33 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
},
|
||||
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": {
|
||||
|
||||
Generated
+2527
-1986
File diff suppressed because it is too large
Load Diff
@@ -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@");
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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),
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user