mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 16:18:57 +00:00
Compare commits
164 Commits
next
...
tale/auth-rework
| 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 | |||
| 931a7f8d14 | |||
| 0080cddb55 | |||
| 0b8ae8fa2d | |||
| 9f0bc4c46a | |||
| 8f445ce347 | |||
| b68722eff0 | |||
| b38b733e9f | |||
| 0d0b5f88a1 | |||
| fad0c99fc9 | |||
| d2c16d2380 | |||
| 2b0f337cb5 | |||
| 5fbb4358ad | |||
| 573dd7e52e | |||
| b5c94f9bc9 | |||
| 696e08dbcc | |||
| 5777a5f509 | |||
| c61a9119a7 | |||
| a710995f97 | |||
| c8861ec978 | |||
| 1a1d7dbe6a | |||
| ec1d34e449 | |||
| 97499ed0c2 | |||
| 403f9c9b0b | |||
| 2618cdd9f6 | |||
| 15d145bf01 | |||
| 40361175e5 | |||
| 034ba25ce3 | |||
| 0df4130217 | |||
| 62325f7c86 | |||
| 42ffe69486 | |||
| 0fb02d0d8b | |||
| d189d75d5d | |||
| 8105b8f94d | |||
| 5953740769 | |||
| 7a345d118c | |||
| 0965e16401 | |||
| e57821df14 | |||
| dfdf5d3ff9 | |||
| 4046c6edb1 | |||
| 7817addbc6 | |||
| e727c48406 | |||
| 85a0f932a7 | |||
| 985d7d9dc6 | |||
| 91254902fa | |||
| 55ffd5e841 | |||
| 82cb74b20b | |||
| e373c4a65e | |||
| 7b4966be02 | |||
| 3d07b941b4 | |||
| 862180df91 | |||
| 11a7c335f7 | |||
| 3d71049afe | |||
| 8948d440fc | |||
| d5dffbaa1d | |||
| d1e2773179 | |||
| 295dd43059 | |||
| b3791385b9 | |||
| 70d535fe8d | |||
| 2ff5dd290f | |||
| 1463a8cc42 | |||
| 87485a81f3 | |||
| 3a3e5ca65e | |||
| 3eff763436 | |||
| 6ad0847653 | |||
| 199ef46ee1 | |||
| e09c5760af | |||
| a824ea3dcf | |||
| bf3fdd47cd | |||
| f6cdad535a | |||
| ba61656fb0 | |||
| d363ed5486 | |||
| b938642da7 | |||
| 45de5f6833 | |||
| afc5d5451a | |||
| ef6563742c | |||
| 86184e3420 | |||
| e868f177aa | |||
| d3d7c7cc0e | |||
| c0c4ecf631 | |||
| 5ea96db816 | |||
| cc533f1b72 | |||
| 3d9b6370bf | |||
| afd512138b | |||
| 08a251cc8c | |||
| 63d1e84ebe | |||
| 20f5342ea3 | |||
| db850942ef | |||
| c9986ba466 | |||
| d331a22951 | |||
| 6be801e9bb | |||
| 299859e279 | |||
| 5090733013 | |||
| 7d77f2cb81 | |||
| c3e07e4e10 | |||
| 1aa921aebe | |||
| 444b2325fb | |||
| c84e9ca4a8 | |||
| d68737e410 | |||
| a68aedc297 | |||
| 1c0561edb8 | |||
| e883a7cd63 | |||
| cc2112a805 | |||
| afde3399e1 | |||
| b1c93512df | |||
| ce4be73faf | |||
| 192cce23b0 | |||
| 7d7e08ee54 | |||
| 06a667b561 | |||
| 75c688fd00 |
@@ -1,31 +1,31 @@
|
|||||||
name: Bug Report
|
name: Bug Report
|
||||||
description: Report an issue with Headplane
|
description: Report an issue with Headplane
|
||||||
assignees: [tale]
|
assignees: [tale]
|
||||||
labels: [bug, triage]
|
labels: ["Bug", "Needs Triage"]
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Description
|
label: Description
|
||||||
description: |
|
description: |
|
||||||
A detailed description of the issue and steps to reproduce it.
|
A detailed description of the issue and steps to reproduce it.
|
||||||
If applicable, include any error messages or screenshots.
|
If applicable, include any error messages or screenshots.
|
||||||
|
|
||||||
If this is not an issue with Headplane, but an issue with your
|
If this is not an issue with Headplane, but an issue with your
|
||||||
environment, please consider opening a discussion instead.
|
environment, please consider opening a discussion instead.
|
||||||
placeholder: e.g. "When I try to upload a file, I get an error message."
|
placeholder: e.g. "When I try to upload a file, I get an error message."
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: Headplane Version
|
label: Headplane Version
|
||||||
description: What version of Headplane are you using?
|
description: What version of Headplane are you using?
|
||||||
placeholder: e.g. "v0.5.5"
|
placeholder: e.g. "v0.6.2"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: Headscale Version
|
label: Headscale Version
|
||||||
description: What version of Headscale are you using?
|
description: What version of Headscale are you using?
|
||||||
placeholder: e.g. "v0.25.1"
|
placeholder: e.g. "v0.27.1"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -1 +1,5 @@
|
|||||||
blank_issues_enabled: false
|
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
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
name: Feature Request
|
name: Feature Request
|
||||||
description: Request a new feature or enhancement for Headplane
|
description: Request a new feature or enhancement for Headplane
|
||||||
assignees: [tale]
|
assignees: [tale]
|
||||||
labels: [enhancement, triage]
|
labels: ["Feature", "Needs Triage"]
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Description
|
label: Description
|
||||||
description: |
|
description: |
|
||||||
A detailed description of the feature you would like to see added.
|
A detailed description of the feature you would like to see added.
|
||||||
Please include any relevant context, such as why this feature is
|
Please include any relevant context, such as why this feature is
|
||||||
important and how it would benefit other users beyond yourself.
|
important and how it would benefit other users beyond yourself.
|
||||||
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
|
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -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
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: DeterminateSystems/nix-installer-action@main
|
uses: DeterminateSystems/nix-installer-action@main
|
||||||
|
|||||||
@@ -2,14 +2,12 @@ name: Build
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- ".zed/**"
|
|
||||||
- "assets/**"
|
- "assets/**"
|
||||||
- "docs/**"
|
- "docs/**"
|
||||||
- "CHANGELOG.md"
|
- "CHANGELOG.md"
|
||||||
- "README.md"
|
- "README.md"
|
||||||
branches:
|
branches:
|
||||||
- "main"
|
- "main"
|
||||||
- "next"
|
|
||||||
pull_request:
|
pull_request:
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
@@ -21,48 +19,44 @@ permissions:
|
|||||||
contents: read # Read access to the repository
|
contents: read # Read access to the repository
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
native:
|
ci:
|
||||||
name: native
|
name: Build and Test
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Setup Mise
|
- name: Setup pnpm
|
||||||
uses: jdx/mise-action@v2
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- name: Set caching paths
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
|
||||||
echo "GO_CACHE=$(go env GOCACHE)" >> $GITHUB_ENV
|
|
||||||
echo "GO_MODCACHE=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- uses: actions/cache@v4
|
|
||||||
name: Setup pnpm cache
|
|
||||||
with:
|
with:
|
||||||
path: ${{ env.STORE_PATH }}
|
run_install: false
|
||||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-pnpm-store-
|
|
||||||
|
|
||||||
- name: Setup Go cache
|
- name: Setup Node.js
|
||||||
uses: actions/cache@v4
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
path: |
|
node-version-file: package.json
|
||||||
${{ env.GO_CACHE }}
|
cache: pnpm
|
||||||
${{ env.GO_MODCACHE }}
|
|
||||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod', '**/go.sum') }}
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
|
||||||
- name: CI pipeline
|
- name: CI pipeline
|
||||||
run: ./build.sh --skip-pnpm-prune
|
run: ./build.sh --skip-pnpm-prune
|
||||||
|
|
||||||
|
- name: Run unit tests
|
||||||
|
run: pnpm run test:unit
|
||||||
|
|
||||||
|
- name: Run integration tests
|
||||||
|
run: pnpm run test:integration
|
||||||
|
|
||||||
nix:
|
nix:
|
||||||
name: nix
|
name: nix
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Install Nix
|
- name: Install Nix
|
||||||
uses: DeterminateSystems/nix-installer-action@main
|
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:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- target: final
|
- target: final
|
||||||
tag_suffix: ''
|
tag_suffix: ""
|
||||||
- target: debug-shell
|
- target: debug-shell
|
||||||
tag_suffix: '-shell'
|
tag_suffix: "-shell"
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Check out the repo
|
- name: Check out the repo
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Docker Metadata
|
- name: Docker Metadata
|
||||||
id: meta
|
id: meta
|
||||||
@@ -70,6 +70,7 @@ jobs:
|
|||||||
cache-to: type=gha,mode=max
|
cache-to: type=gha,mode=max
|
||||||
build-args: |
|
build-args: |
|
||||||
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
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] }}
|
- name: Attestation Provenance for ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
|
||||||
uses: actions/attest-build-provenance@v2
|
uses: actions/attest-build-provenance@v2
|
||||||
id: attest
|
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,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"endOfLine": "lf",
|
||||||
|
"experimentalSortImports": {},
|
||||||
|
"experimentalTailwindcss": {},
|
||||||
|
"ignorePatterns": [],
|
||||||
|
"printWidth": 100,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
+144
@@ -0,0 +1,144 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["unicorn", "typescript", "oxc"],
|
||||||
|
"categories": {},
|
||||||
|
"rules": {
|
||||||
|
"constructor-super": "warn",
|
||||||
|
"for-direction": "warn",
|
||||||
|
"no-async-promise-executor": "warn",
|
||||||
|
"no-caller": "warn",
|
||||||
|
"no-class-assign": "warn",
|
||||||
|
"no-compare-neg-zero": "warn",
|
||||||
|
"no-cond-assign": "warn",
|
||||||
|
"no-const-assign": "warn",
|
||||||
|
"no-constant-binary-expression": "warn",
|
||||||
|
"no-constant-condition": "warn",
|
||||||
|
"no-control-regex": "warn",
|
||||||
|
"no-debugger": "warn",
|
||||||
|
"no-delete-var": "warn",
|
||||||
|
"no-dupe-class-members": "warn",
|
||||||
|
"no-dupe-else-if": "warn",
|
||||||
|
"no-dupe-keys": "warn",
|
||||||
|
"no-duplicate-case": "warn",
|
||||||
|
"no-empty-character-class": "warn",
|
||||||
|
"no-empty-pattern": "warn",
|
||||||
|
"no-empty-static-block": "warn",
|
||||||
|
"no-eval": "warn",
|
||||||
|
"no-ex-assign": "warn",
|
||||||
|
"no-extra-boolean-cast": "warn",
|
||||||
|
"no-func-assign": "warn",
|
||||||
|
"no-global-assign": "warn",
|
||||||
|
"no-import-assign": "warn",
|
||||||
|
"no-invalid-regexp": "warn",
|
||||||
|
"no-irregular-whitespace": "warn",
|
||||||
|
"no-loss-of-precision": "warn",
|
||||||
|
"no-new-native-nonconstructor": "warn",
|
||||||
|
"no-nonoctal-decimal-escape": "warn",
|
||||||
|
"no-obj-calls": "warn",
|
||||||
|
"no-self-assign": "warn",
|
||||||
|
"no-setter-return": "warn",
|
||||||
|
"no-shadow-restricted-names": "warn",
|
||||||
|
"no-sparse-arrays": "warn",
|
||||||
|
"no-this-before-super": "warn",
|
||||||
|
"no-unassigned-vars": "warn",
|
||||||
|
"no-unsafe-finally": "warn",
|
||||||
|
"no-unsafe-negation": "warn",
|
||||||
|
"no-unsafe-optional-chaining": "warn",
|
||||||
|
"no-unused-expressions": "warn",
|
||||||
|
"no-unused-labels": "warn",
|
||||||
|
"no-unused-private-class-members": "warn",
|
||||||
|
"no-unused-vars": "warn",
|
||||||
|
"no-useless-backreference": "warn",
|
||||||
|
"no-useless-catch": "warn",
|
||||||
|
"no-useless-escape": "warn",
|
||||||
|
"no-useless-rename": "warn",
|
||||||
|
"no-with": "warn",
|
||||||
|
"require-yield": "warn",
|
||||||
|
"use-isnan": "warn",
|
||||||
|
"valid-typeof": "warn",
|
||||||
|
"oxc/bad-array-method-on-arguments": "warn",
|
||||||
|
"oxc/bad-char-at-comparison": "warn",
|
||||||
|
"oxc/bad-comparison-sequence": "warn",
|
||||||
|
"oxc/bad-min-max-func": "warn",
|
||||||
|
"oxc/bad-object-literal-comparison": "warn",
|
||||||
|
"oxc/bad-replace-all-arg": "warn",
|
||||||
|
"oxc/const-comparisons": "warn",
|
||||||
|
"oxc/double-comparisons": "warn",
|
||||||
|
"oxc/erasing-op": "warn",
|
||||||
|
"oxc/missing-throw": "warn",
|
||||||
|
"oxc/number-arg-out-of-range": "warn",
|
||||||
|
"oxc/only-used-in-recursion": "warn",
|
||||||
|
"oxc/uninvoked-array-callback": "warn",
|
||||||
|
"typescript/await-thenable": "warn",
|
||||||
|
"typescript/no-array-delete": "warn",
|
||||||
|
"typescript/no-base-to-string": "warn",
|
||||||
|
"typescript/no-duplicate-enum-values": "warn",
|
||||||
|
"typescript/no-duplicate-type-constituents": "warn",
|
||||||
|
"typescript/no-extra-non-null-assertion": "warn",
|
||||||
|
"typescript/no-floating-promises": "warn",
|
||||||
|
"typescript/no-for-in-array": "warn",
|
||||||
|
"typescript/no-implied-eval": "warn",
|
||||||
|
"typescript/no-meaningless-void-operator": "warn",
|
||||||
|
"typescript/no-misused-new": "warn",
|
||||||
|
"typescript/no-misused-spread": "warn",
|
||||||
|
"typescript/no-non-null-asserted-optional-chain": "warn",
|
||||||
|
"typescript/no-redundant-type-constituents": "warn",
|
||||||
|
"typescript/no-this-alias": "warn",
|
||||||
|
"typescript/no-unnecessary-parameter-property-assignment": "warn",
|
||||||
|
"typescript/no-unsafe-declaration-merging": "warn",
|
||||||
|
"typescript/no-unsafe-unary-minus": "warn",
|
||||||
|
"typescript/no-useless-empty-export": "warn",
|
||||||
|
"typescript/no-wrapper-object-types": "warn",
|
||||||
|
"typescript/prefer-as-const": "warn",
|
||||||
|
"typescript/require-array-sort-compare": "warn",
|
||||||
|
"typescript/restrict-template-expressions": "warn",
|
||||||
|
"typescript/triple-slash-reference": "warn",
|
||||||
|
"typescript/unbound-method": "warn",
|
||||||
|
"unicorn/no-await-in-promise-methods": "warn",
|
||||||
|
"unicorn/no-empty-file": "warn",
|
||||||
|
"unicorn/no-invalid-fetch-options": "warn",
|
||||||
|
"unicorn/no-invalid-remove-event-listener": "warn",
|
||||||
|
"unicorn/no-new-array": "warn",
|
||||||
|
"unicorn/no-single-promise-in-promise-methods": "warn",
|
||||||
|
"unicorn/no-thenable": "warn",
|
||||||
|
"unicorn/no-unnecessary-await": "warn",
|
||||||
|
"unicorn/no-useless-fallback-in-spread": "warn",
|
||||||
|
"unicorn/no-useless-length-check": "warn",
|
||||||
|
"unicorn/no-useless-spread": "warn",
|
||||||
|
"unicorn/prefer-set-size": "warn",
|
||||||
|
"unicorn/prefer-string-starts-ends-with": "warn"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"jsx-a11y": {
|
||||||
|
"polymorphicPropName": null,
|
||||||
|
"components": {},
|
||||||
|
"attributes": {}
|
||||||
|
},
|
||||||
|
"next": {
|
||||||
|
"rootDir": []
|
||||||
|
},
|
||||||
|
"react": {
|
||||||
|
"formComponents": [],
|
||||||
|
"linkComponents": [],
|
||||||
|
"version": null
|
||||||
|
},
|
||||||
|
"jsdoc": {
|
||||||
|
"ignorePrivate": false,
|
||||||
|
"ignoreInternal": false,
|
||||||
|
"ignoreReplacesDocs": true,
|
||||||
|
"overrideReplacesDocs": true,
|
||||||
|
"augmentsExtendsReplacesDocs": false,
|
||||||
|
"implementsReplacesDocs": false,
|
||||||
|
"exemptDestructuredRootsFromChecks": false,
|
||||||
|
"tagNamePreference": {}
|
||||||
|
},
|
||||||
|
"vitest": {
|
||||||
|
"typecheck": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"builtin": true
|
||||||
|
},
|
||||||
|
"globals": {},
|
||||||
|
"ignorePatterns": []
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
+97
-22
@@ -1,20 +1,60 @@
|
|||||||
# 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)).
|
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
|
||||||
- Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)).
|
- Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)).
|
||||||
- Implemented the ability to customize the build with a custom script (see `./build.sh --help` for more information).
|
- Implemented the ability to customize the build with a custom script (see `./build.sh --help` for more information).
|
||||||
|
- Attempt to warn against misconfigured cookie settings on the login page.
|
||||||
|
- Made `server.cookie_max_age` and `server.cookie_domain` configurable (closes [#348](https://github.com/tale/headplane/issues/348)).
|
||||||
|
- Re-worked the configuration loading system with several enhancements:
|
||||||
|
- It is now possible to skip a configuration file and only use environment variables (closes [#150](https://github.com/tale/headplane/issues/150)).
|
||||||
|
- Secret path loading has been reworked from the ground up to be more reliable (closes [#334](https://github.com/tale/headplane/issues/334)).
|
||||||
|
- Added better testing and validation for configuration loading
|
||||||
|
- Re-worked the OIDC integration to adhere to the correct standards and surface more errors to the user.
|
||||||
|
- 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)).
|
||||||
|
- Fixed an issue where the website favicon would not load correctly (closes [#323](https://github.com/tale/headplane/issues/323)).
|
||||||
|
- Correctly handle invalid ACL policy inserts on Headscale 0.27+ (closes [#383](https://github.com/tale/headplane/issues/383)).
|
||||||
|
- Prevent a machine from changing its owner to itself (closes [#373](https://github.com/tale/headplane/issues/373)).
|
||||||
|
- Added an `/admin/api/info` route that can expose sensitive information if `server.info_secret` is set in the configuration (closes [#324](https://github.com/tale/headplane/issues/324)).
|
||||||
|
- Correctly apply Gravatar profile pictures on the user page if applicable (closes [#405](https://github.com/tale/headplane/issues/405)).
|
||||||
|
- Machine key registration no longer works if the key isn't 24 characters long (closes [#415](https://github.com/tale/headplane/issues/415)).
|
||||||
|
- Fixed some mobile CSS issues across the application (closes [#401](https://github.com/tale/headplane/issues/401)).
|
||||||
|
- 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 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)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 0.6.1 (October 12, 2025)
|
# 0.6.1 (October 12, 2025)
|
||||||
|
|
||||||
- **Headplane now supports connecting to machines via SSH in the web browser.**
|
- **Headplane now supports connecting to machines via SSH in the web browser.**
|
||||||
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
|
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
|
||||||
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
|
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
|
||||||
- Begin using a new SQLite database file in `/var/lib/headplane/hp_persist.db`.
|
- Begin using a new SQLite database file in `/var/lib/headplane/hp_persist.db`.
|
||||||
- The database is created automatically if it does not exist.
|
- The database is created automatically if it does not exist.
|
||||||
- It currently stores SSH connection details and HostInfo for the agent.
|
- It currently stores SSH connection details and HostInfo for the agent.
|
||||||
- User information is automatically migrated from the previous database.
|
- User information is automatically migrated from the previous database.
|
||||||
- The docker container now runs in a distroless image (closes [#255](https://github.com/tale/headplane/issues/255)).
|
- The docker container now runs in a distroless image (closes [#255](https://github.com/tale/headplane/issues/255)).
|
||||||
- A debug version of the container that runs as root and has a shell is available as `ghcr.io/tale/headplane:<version>-shell`.
|
- A debug version of the container that runs as root and has a shell is available as `ghcr.io/tale/headplane:<version>-shell`.
|
||||||
- Removing a Split DNS record will no longer make the split domain unresolvable by clients (closes [#231](https://github.com/tale/headplane/issues/231)).
|
- Removing a Split DNS record will no longer make the split domain unresolvable by clients (closes [#231](https://github.com/tale/headplane/issues/231)).
|
||||||
- Reintroduce the toggle for overriding local DNS settings in the Headscale config (closes [#236](https://github.com/tale/headplane/issues/236)).
|
- Reintroduce the toggle for overriding local DNS settings in the Headscale config (closes [#236](https://github.com/tale/headplane/issues/236)).
|
||||||
- Prefer cross-compiling in the Dockerfile to speed up builds while still supporting multiple architectures.
|
- Prefer cross-compiling in the Dockerfile to speed up builds while still supporting multiple architectures.
|
||||||
@@ -22,32 +62,33 @@
|
|||||||
- Implement more accurate guessing on the PID with the `/proc` integration (via [#219](https://github.com/tale/headplane/pull/219)).
|
- Implement more accurate guessing on the PID with the `/proc` integration (via [#219](https://github.com/tale/headplane/pull/219)).
|
||||||
- Usernames will now correctly fall back to emails if not provided (via [#257](https://github.com/tale/headplane/pull/257)).
|
- Usernames will now correctly fall back to emails if not provided (via [#257](https://github.com/tale/headplane/pull/257)).
|
||||||
- Configuration loading via paths is now supported for sensitive values (via [#283](https://github.com/tale/headplane/pulls/283))
|
- Configuration loading via paths is now supported for sensitive values (via [#283](https://github.com/tale/headplane/pulls/283))
|
||||||
- Options like `server.cookie_secret_path` can override `server.cookie_secret`
|
- Options like `server.cookie_secret_path` can override `server.cookie_secret`
|
||||||
- Environment variables are interpolatable into these paths
|
- Environment variables are interpolatable into these paths
|
||||||
- See the full reference in the [docs](https://github.com/tale/headplane/blob/main/docs/Configuration.md#sensitive-values)
|
- See the full reference in the [docs](https://github.com/tale/headplane/blob/main/docs/Configuration.md#sensitive-values)
|
||||||
- The nix overlay build is fixed for the SSH module (via [#282](https://github.com/tale/headplane/pull/282))
|
- The nix overlay build is fixed for the SSH module (via [#282](https://github.com/tale/headplane/pull/282))
|
||||||
- Switch our build processes to use TypeScript Go and Rolldown Vite for better build and type-check performance.
|
- Switch our build processes to use TypeScript Go and Rolldown Vite for better build and type-check performance.
|
||||||
- Cookies are now encrypted JWTs, preserving API key secrets (*GHSA-wrqq-v7qw-r5w7*)
|
- Cookies are now encrypted JWTs, preserving API key secrets (_GHSA-wrqq-v7qw-r5w7_)
|
||||||
- OIDC profile pictures are now available from Gravatar by setting `oidc.profile_picture_source` to `gravatar` (closes [#232](https://github.com/tale/headplane/issues/232)).
|
- OIDC profile pictures are now available from Gravatar by setting `oidc.profile_picture_source` to `gravatar` (closes [#232](https://github.com/tale/headplane/issues/232)).
|
||||||
- OIDC now allows passing many custom parameters:
|
- OIDC now allows passing many custom parameters:
|
||||||
- `oidc.authorization_endpoint`, `oidc.token_endpoint`, and `oidc.userinfo_endpoint` can be overridden to support non-standard providers or scenarios without discovery (closes [#117](https://github.com/tale/headplane/issues/117)).
|
- `oidc.authorization_endpoint`, `oidc.token_endpoint`, and `oidc.userinfo_endpoint` can be overridden to support non-standard providers or scenarios without discovery (closes [#117](https://github.com/tale/headplane/issues/117)).
|
||||||
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
|
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
|
||||||
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
|
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 0.6.0 (May 25, 2025)
|
# 0.6.0 (May 25, 2025)
|
||||||
|
|
||||||
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
|
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
|
||||||
- Breaking API changes with routes and pre auth keys are now supported (closes [#204](https://github.com/tale/headplane/issues/204)).
|
- Breaking API changes with routes and pre auth keys are now supported (closes [#204](https://github.com/tale/headplane/issues/204)).
|
||||||
- Older versions of Headscale will not work with Headplane.
|
- Older versions of Headscale will not work with Headplane.
|
||||||
|
|
||||||
- OIDC authorization restrictions can now be controlled from the settings UI. (closes [#102](https://github.com/tale/headplane/issues/102))
|
- OIDC authorization restrictions can now be controlled from the settings UI. (closes [#102](https://github.com/tale/headplane/issues/102))
|
||||||
- The required permission role for this is **IT Admin** or **Admin/Owner** and require the Headscale configuration.
|
- The required permission role for this is **IT Admin** or **Admin/Owner** and require the Headscale configuration.
|
||||||
- Changes made will modify the `oidc.allowed_{domains,groups,users}` fields in the Headscale config file.
|
- Changes made will modify the `oidc.allowed_{domains,groups,users}` fields in the Headscale config file.
|
||||||
- The Pre-Auth keys page has been fully reworked (closes [#179](https://github.com/tale/headplane/issues/179), [#143](https://github.com/tale/headplane/issues/143)).
|
- The Pre-Auth keys page has been fully reworked (closes [#179](https://github.com/tale/headplane/issues/179), [#143](https://github.com/tale/headplane/issues/143)).
|
||||||
- The Headplane agent is now available as an integration (closes [#65](https://github.com/tale/headplane/issues/65)).
|
- The Headplane agent is now available as an integration (closes [#65](https://github.com/tale/headplane/issues/65)).
|
||||||
- The agent runs as an embedded process alongside the Headplane server and reports host information and system metrics.
|
- The agent runs as an embedded process alongside the Headplane server and reports host information and system metrics.
|
||||||
- Refer to the `integrations.agent` section of the config file for more information and how to enable it.
|
- Refer to the `integrations.agent` section of the config file for more information and how to enable it.
|
||||||
- Requests to `/admin` will now be redirected to `/admin/` to prevent issues with the React Router (works with custom prefixes, closes [#173](https://github.com/tale/headplane/issues/173)).
|
- Requests to `/admin` will now be redirected to `/admin/` to prevent issues with the React Router (works with custom prefixes, closes [#173](https://github.com/tale/headplane/issues/173)).
|
||||||
- The Login page has been simplified and separately reports errors versus incorrect API keys (closes [#186](https://github.com/tale/headplane/issues/186)).
|
- The Login page has been simplified and separately reports errors versus incorrect API keys (closes [#186](https://github.com/tale/headplane/issues/186)).
|
||||||
- The machine actions backend has been reworked to better handle errors and provide more information to the user (closes [#185](https://github.com/tale/headplane/issues/185)).
|
- The machine actions backend has been reworked to better handle errors and provide more information to the user (closes [#185](https://github.com/tale/headplane/issues/185)).
|
||||||
@@ -64,11 +105,13 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.5.10 (April 4, 2025)
|
# 0.5.10 (April 4, 2025)
|
||||||
|
|
||||||
- Fix an issue where other preferences to skip onboarding affected every user.
|
- Fix an issue where other preferences to skip onboarding affected every user.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 0.5.9 (April 3, 2025)
|
# 0.5.9 (April 3, 2025)
|
||||||
|
|
||||||
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
|
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
|
||||||
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
|
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
|
||||||
- Suppress hydration errors for any fields with a date in it.
|
- Suppress hydration errors for any fields with a date in it.
|
||||||
@@ -76,6 +119,7 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.5.8 (April 3, 2025)
|
# 0.5.8 (April 3, 2025)
|
||||||
|
|
||||||
- You can now skip the onboarding page if desired.
|
- You can now skip the onboarding page if desired.
|
||||||
- Added the UI to change user roles in the dashboard.
|
- Added the UI to change user roles in the dashboard.
|
||||||
- Fixed an issue where integrations would throw instead of loading properly.
|
- Fixed an issue where integrations would throw instead of loading properly.
|
||||||
@@ -86,6 +130,7 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.5.7 (April 2, 2025)
|
# 0.5.7 (April 2, 2025)
|
||||||
|
|
||||||
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
|
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -93,9 +138,10 @@
|
|||||||
# 0.5.6 (April 2, 2025)
|
# 0.5.6 (April 2, 2025)
|
||||||
|
|
||||||
### IMPORTANT
|
### IMPORTANT
|
||||||
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously *ANY* accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
|
|
||||||
|
|
||||||
Implemented *proper* authentication methods for OIDC.
|
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously _ANY_ accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
|
||||||
|
|
||||||
|
Implemented _proper_ authentication methods for OIDC.
|
||||||
This is a large update and copies the permission system from Tailscale.
|
This is a large update and copies the permission system from Tailscale.
|
||||||
Permissions are not automatically derived from OIDC, but they can be configured via the UI.
|
Permissions are not automatically derived from OIDC, but they can be configured via the UI.
|
||||||
Additionally, certain roles give certain capabilities, limiting access to parts of the dashboard.
|
Additionally, certain roles give certain capabilities, limiting access to parts of the dashboard.
|
||||||
@@ -103,6 +149,7 @@ By default, new users will have a `member` role which forbids access to the UI.
|
|||||||
If there are no users, the first user will be given an `owner` role which cannot be removed.
|
If there are no users, the first user will be given an `owner` role which cannot be removed.
|
||||||
|
|
||||||
**Changes**:
|
**Changes**:
|
||||||
|
|
||||||
- Switched the internal server to use `hono` for better performance.
|
- Switched the internal server to use `hono` for better performance.
|
||||||
- Fixed an issue that caused dialogs to randomly refocus every 3 seconds.
|
- Fixed an issue that caused dialogs to randomly refocus every 3 seconds.
|
||||||
- Headplane will not send API requests when the tab is not focused.
|
- Headplane will not send API requests when the tab is not focused.
|
||||||
@@ -113,11 +160,13 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.5.5 (March 18, 2025)
|
# 0.5.5 (March 18, 2025)
|
||||||
|
|
||||||
- Hotfix an issue that caused Headplane to crash if no agents are available
|
- Hotfix an issue that caused Headplane to crash if no agents are available
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 0.5.4 (March 18, 2025)
|
# 0.5.4 (March 18, 2025)
|
||||||
|
|
||||||
- Fixed a typo in the Kubernetes documentation
|
- Fixed a typo in the Kubernetes documentation
|
||||||
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
|
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
|
||||||
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
|
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
|
||||||
@@ -134,6 +183,7 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.5.3 (March 1, 2025)
|
# 0.5.3 (March 1, 2025)
|
||||||
|
|
||||||
- Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111))
|
- Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111))
|
||||||
- Added an ARIA indicator for when an input is required and fixed the confirm buttons (fixed [#116](https://github.com/tale/headplane/issues/116))
|
- Added an ARIA indicator for when an input is required and fixed the confirm buttons (fixed [#116](https://github.com/tale/headplane/issues/116))
|
||||||
- Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112))
|
- Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112))
|
||||||
@@ -141,11 +191,13 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.5.2 (February 28, 2025)
|
# 0.5.2 (February 28, 2025)
|
||||||
|
|
||||||
- Hotfixed an issue where the server bundle got reloaded on each request
|
- Hotfixed an issue where the server bundle got reloaded on each request
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 0.5.1 (February 28, 2025)
|
# 0.5.1 (February 28, 2025)
|
||||||
|
|
||||||
- Fixed an issue that caused the entire server to crash on start
|
- Fixed an issue that caused the entire server to crash on start
|
||||||
- Fixed the published semver tags from Docker
|
- Fixed the published semver tags from Docker
|
||||||
- Fixed the Kubernetes integration not reading the config
|
- Fixed the Kubernetes integration not reading the config
|
||||||
@@ -153,6 +205,7 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.5 (February 27, 2025)
|
# 0.5 (February 27, 2025)
|
||||||
|
|
||||||
> This release is a major overhaul and contains a significant breaking change.
|
> This release is a major overhaul and contains a significant breaking change.
|
||||||
> We now use a config file for all settings instead of environment variables.
|
> We now use a config file for all settings instead of environment variables.
|
||||||
> Please see [config.example.yaml](/config.example.yaml) for the new format.
|
> Please see [config.example.yaml](/config.example.yaml) for the new format.
|
||||||
@@ -170,11 +223,13 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.4.1 (January 18, 2025)
|
# 0.4.1 (January 18, 2025)
|
||||||
|
|
||||||
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
|
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 0.4.0 (January 18, 2025)
|
# 0.4.0 (January 18, 2025)
|
||||||
|
|
||||||
- Switched from Remix.run to React-Router
|
- Switched from Remix.run to React-Router
|
||||||
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
|
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
|
||||||
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
|
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
|
||||||
@@ -190,11 +245,13 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.3.9 (December 6, 2024)
|
# 0.3.9 (December 6, 2024)
|
||||||
|
|
||||||
- Fixed a race condition bug in the OIDC validation code
|
- Fixed a race condition bug in the OIDC validation code
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 0.3.8 (December 6, 2024)
|
# 0.3.8 (December 6, 2024)
|
||||||
|
|
||||||
- Added a little HTML footer to show the login page and link to a donation page.
|
- Added a little HTML footer to show the login page and link to a donation page.
|
||||||
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
|
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
|
||||||
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
|
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
|
||||||
@@ -202,6 +259,7 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.3.7 (November 30, 2024)
|
# 0.3.7 (November 30, 2024)
|
||||||
|
|
||||||
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
|
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
|
||||||
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
|
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
|
||||||
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
|
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
|
||||||
@@ -210,18 +268,21 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.3.6 (November 20, 2024)
|
# 0.3.6 (November 20, 2024)
|
||||||
|
|
||||||
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
|
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
|
||||||
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
|
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 0.3.5 (November 8, 2024)
|
# 0.3.5 (November 8, 2024)
|
||||||
|
|
||||||
- Quickfix a bug where environment variables are ignored on the server.
|
- Quickfix a bug where environment variables are ignored on the server.
|
||||||
- Remove a nagging error about missing cookie since that happens when signed out.
|
- Remove a nagging error about missing cookie since that happens when signed out.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 0.3.4 (November 7, 2024)
|
# 0.3.4 (November 7, 2024)
|
||||||
|
|
||||||
- Clicking on the machine name in the users page now takes you to the machine overview page.
|
- Clicking on the machine name in the users page now takes you to the machine overview page.
|
||||||
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
|
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
|
||||||
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
|
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
|
||||||
@@ -234,6 +295,7 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.3.3 (October 28, 2024)
|
# 0.3.3 (October 28, 2024)
|
||||||
|
|
||||||
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
|
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
|
||||||
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
|
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
|
||||||
- Improved ACL editor performance by switching back to CodeMirror 6
|
- Improved ACL editor performance by switching back to CodeMirror 6
|
||||||
@@ -243,6 +305,7 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.3.2 (October 11, 2024)
|
# 0.3.2 (October 11, 2024)
|
||||||
|
|
||||||
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
|
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
|
||||||
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
|
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
|
||||||
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
|
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
|
||||||
@@ -252,6 +315,7 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.3.1 (October 3, 2024)
|
# 0.3.1 (October 3, 2024)
|
||||||
|
|
||||||
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
|
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
|
||||||
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
|
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
|
||||||
- Added the option for debug logs with the `DEBUG=true` environment variable.
|
- Added the option for debug logs with the `DEBUG=true` environment variable.
|
||||||
@@ -259,12 +323,14 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.3.0 (September 25, 2024)
|
# 0.3.0 (September 25, 2024)
|
||||||
|
|
||||||
- Bumped the minimum supported version of Headscale to 0.23.
|
- Bumped the minimum supported version of Headscale to 0.23.
|
||||||
- Updated the UI to respect `dns.use_username_in_magic_dns`.
|
- Updated the UI to respect `dns.use_username_in_magic_dns`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 0.2.4 (August 24, 2024)
|
# 0.2.4 (August 24, 2024)
|
||||||
|
|
||||||
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
|
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
|
||||||
- Removed the `ACL_FILE` environment variable since it's no longer needed.
|
- Removed the `ACL_FILE` environment variable since it's no longer needed.
|
||||||
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
|
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
|
||||||
@@ -273,6 +339,7 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.2.3 (August 23, 2024)
|
# 0.2.3 (August 23, 2024)
|
||||||
|
|
||||||
- Change the minimum required version of Headscale to 0.23-beta2
|
- Change the minimum required version of Headscale to 0.23-beta2
|
||||||
- Support the new API policy mode for Headscale 0.23-beta1
|
- Support the new API policy mode for Headscale 0.23-beta1
|
||||||
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
|
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
|
||||||
@@ -281,6 +348,7 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.2.2 (August 2, 2024)
|
# 0.2.2 (August 2, 2024)
|
||||||
|
|
||||||
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
|
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
|
||||||
- Added a new logger utility that shows categories, levels, and timestamps.
|
- Added a new logger utility that shows categories, levels, and timestamps.
|
||||||
- Reimplemented the integration system to be more resilient and log more information.
|
- Reimplemented the integration system to be more resilient and log more information.
|
||||||
@@ -289,6 +357,7 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.2.1 (July 7, 2024)
|
# 0.2.1 (July 7, 2024)
|
||||||
|
|
||||||
- Added the ability to manage custom DNS records on your Tailnet.
|
- Added the ability to manage custom DNS records on your Tailnet.
|
||||||
- ACL tags for machines are now able to be changed via the machine menu.
|
- ACL tags for machines are now able to be changed via the machine menu.
|
||||||
- Fixed a bug where the ACL editor did not show the diffs correctly.
|
- Fixed a bug where the ACL editor did not show the diffs correctly.
|
||||||
@@ -297,12 +366,14 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.2.0 (June 23, 2024)
|
# 0.2.0 (June 23, 2024)
|
||||||
|
|
||||||
- Fix the dropdown options for machines not working on the machines page.
|
- Fix the dropdown options for machines not working on the machines page.
|
||||||
- Add an option to change the machine owner in the dropdown (aside from the users page).
|
- Add an option to change the machine owner in the dropdown (aside from the users page).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# 0.1.9 (June 2, 2024)
|
# 0.1.9 (June 2, 2024)
|
||||||
|
|
||||||
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
|
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
|
||||||
- Utilize magic DNS hostnames for the machine overview page.
|
- Utilize magic DNS hostnames for the machine overview page.
|
||||||
- Fixed the expiry issue once and for all.
|
- Fixed the expiry issue once and for all.
|
||||||
@@ -311,6 +382,7 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.1.8 (June 2, 2024)
|
# 0.1.8 (June 2, 2024)
|
||||||
|
|
||||||
- Built basic functionality for the machine overview page (by machine ID).
|
- Built basic functionality for the machine overview page (by machine ID).
|
||||||
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
|
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
|
||||||
- Prevent users from being deleted if they still have ownership of machines.
|
- Prevent users from being deleted if they still have ownership of machines.
|
||||||
@@ -319,6 +391,7 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.1.7 (May 30, 2024)
|
# 0.1.7 (May 30, 2024)
|
||||||
|
|
||||||
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
|
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
|
||||||
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
|
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
|
||||||
- Made the strict configuration loader more lenient to allow for more flexibility.
|
- Made the strict configuration loader more lenient to allow for more flexibility.
|
||||||
@@ -329,6 +402,7 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.1.6 (May 22, 2024)
|
# 0.1.6 (May 22, 2024)
|
||||||
|
|
||||||
- Added experimental support for advanced integration without Docker.
|
- Added experimental support for advanced integration without Docker.
|
||||||
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
|
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
|
||||||
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
|
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
|
||||||
@@ -336,6 +410,7 @@ If there are no users, the first user will be given an `owner` role which cannot
|
|||||||
---
|
---
|
||||||
|
|
||||||
# 0.1.5 (May 20, 2024)
|
# 0.1.5 (May 20, 2024)
|
||||||
|
|
||||||
- Robust configuration handling with fallbacks based on the headscale source.
|
- Robust configuration handling with fallbacks based on the headscale source.
|
||||||
- Support for `client_secret_path` on configuration file based OIDC.
|
- Support for `client_secret_path` on configuration file based OIDC.
|
||||||
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
|
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
|
||||||
|
|||||||
+14
-3
@@ -11,14 +11,16 @@ ARG TARGETOS
|
|||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG IMAGE_TAG
|
ARG IMAGE_TAG
|
||||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 IMAGE_TAG=$IMAGE_TAG \
|
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 IMAGE_TAG=$IMAGE_TAG \
|
||||||
./build.sh --wasm --agent --fake-shell \
|
./build.sh --wasm --agent --fake-shell --healthcheck \
|
||||||
--wasm-output /bin/hp_ssh.wasm \
|
--wasm-output /bin/hp_ssh.wasm \
|
||||||
--agent-output /bin/hp_agent \
|
--agent-output /bin/hp_agent \
|
||||||
--fake-shell-output /bin/fake-sh
|
--fake-shell-output /bin/fake-sh \
|
||||||
|
--healthcheck-output /bin/hp_healthcheck
|
||||||
|
|
||||||
RUN chmod +x /bin/hp_ssh.wasm
|
RUN chmod +x /bin/hp_ssh.wasm
|
||||||
RUN chmod +x /bin/hp_agent
|
RUN chmod +x /bin/hp_agent
|
||||||
RUN chmod +x /bin/fake-sh
|
RUN chmod +x /bin/fake-sh
|
||||||
|
RUN chmod +x /bin/hp_healthcheck
|
||||||
|
|
||||||
# Folder needs to exist for later stages
|
# Folder needs to exist for later stages
|
||||||
RUN mkdir -p /var/lib/headplane/agent
|
RUN mkdir -p /var/lib/headplane/agent
|
||||||
@@ -35,7 +37,8 @@ COPY --from=go-base /bin/wasm_exec.js /run/app/wasm_exec.js
|
|||||||
RUN ./build.sh --app --app-install-only
|
RUN ./build.sh --app --app-install-only
|
||||||
|
|
||||||
COPY . .
|
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
|
FROM gcr.io/distroless/nodejs22-debian12:latest AS final
|
||||||
COPY --from=js-base /run/build /app/build
|
COPY --from=js-base /run/build /app/build
|
||||||
@@ -49,6 +52,10 @@ COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
|||||||
COPY --from=go-base /bin/fake-sh /bin/sh
|
COPY --from=go-base /bin/fake-sh /bin/sh
|
||||||
COPY --from=go-base /bin/fake-sh /bin/bash
|
COPY --from=go-base /bin/fake-sh /bin/bash
|
||||||
|
|
||||||
|
COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD ["/bin/hp_healthcheck"]
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
CMD [ "/app/build/server/index.js" ]
|
CMD [ "/app/build/server/index.js" ]
|
||||||
|
|
||||||
@@ -61,6 +68,10 @@ COPY --from=js-base /run/node_modules /app/node_modules
|
|||||||
|
|
||||||
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
|
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
|
||||||
COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
||||||
|
COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||||
|
CMD ["/bin/hp_healthcheck"]
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
CMD [ "node", "/app/build/server/index.js" ]
|
CMD [ "node", "/app/build/server/index.js" ]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import toast from '~/utils/toast';
|
|||||||
|
|
||||||
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
|
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
|
||||||
isCopyable?: boolean;
|
isCopyable?: boolean;
|
||||||
children: string | string[];
|
children: string | string[] | number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Code({ isCopyable, children, className }: CodeProps) {
|
export default function Code({ isCopyable, children, className }: CodeProps) {
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ function Panel(props: DialogPanelProps) {
|
|||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
{...dialogProps}
|
{...dialogProps}
|
||||||
|
className={cn(
|
||||||
|
'outline-hidden rounded-3xl w-full max-w-lg',
|
||||||
|
'bg-white dark:bg-headplane-900',
|
||||||
|
)}
|
||||||
|
method={method ?? 'POST'}
|
||||||
onSubmit={(event) => {
|
onSubmit={(event) => {
|
||||||
if (onSubmit) {
|
if (onSubmit) {
|
||||||
onSubmit(event);
|
onSubmit(event);
|
||||||
@@ -114,12 +119,7 @@ function Panel(props: DialogPanelProps) {
|
|||||||
|
|
||||||
close?.();
|
close?.();
|
||||||
}}
|
}}
|
||||||
method={method ?? 'POST'}
|
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
|
||||||
'outline-hidden rounded-3xl w-full max-w-lg',
|
|
||||||
'bg-white dark:bg-headplane-900',
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
<Card className="w-full max-w-lg" variant="flat">
|
<Card className="w-full max-w-lg" variant="flat">
|
||||||
{children}
|
{children}
|
||||||
@@ -130,9 +130,9 @@ function Panel(props: DialogPanelProps) {
|
|||||||
<>
|
<>
|
||||||
<Button onPress={close}>Cancel</Button>
|
<Button onPress={close}>Cancel</Button>
|
||||||
<Button
|
<Button
|
||||||
|
isDisabled={isDisabled}
|
||||||
type="submit"
|
type="submit"
|
||||||
variant={variant === 'destructive' ? 'danger' : 'heavy'}
|
variant={variant === 'destructive' ? 'danger' : 'heavy'}
|
||||||
isDisabled={isDisabled}
|
|
||||||
>
|
>
|
||||||
Confirm
|
Confirm
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
import { AlertCircle } from 'lucide-react';
|
|
||||||
import { isRouteErrorResponse, useRouteError } from 'react-router';
|
|
||||||
import ResponseError from '~/server/headscale/api-error';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
import Card from './Card';
|
|
||||||
import Code from './Code';
|
|
||||||
|
|
||||||
interface Props {
|
|
||||||
type?: 'full' | 'embedded';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getErrorMessage(error: Error | unknown): {
|
|
||||||
title: string;
|
|
||||||
message: string;
|
|
||||||
} {
|
|
||||||
if (error instanceof ResponseError) {
|
|
||||||
if (error.responseObject?.message) {
|
|
||||||
return {
|
|
||||||
title: 'Headscale Error',
|
|
||||||
message: String(error.responseObject.message),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: 'Headscale Error',
|
|
||||||
message: error.response,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!(error instanceof Error)) {
|
|
||||||
return {
|
|
||||||
title: 'Unknown Error',
|
|
||||||
message: String(error),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let rootError = error;
|
|
||||||
|
|
||||||
// Traverse the error chain to find the root cause
|
|
||||||
if (error.cause) {
|
|
||||||
rootError = error.cause as Error;
|
|
||||||
while (rootError.cause) {
|
|
||||||
rootError = rootError.cause as Error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we are aggregate, concat into a single message
|
|
||||||
if (rootError instanceof AggregateError) {
|
|
||||||
throw new Error('Unhandled AggregateError');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: 'Error',
|
|
||||||
message: rootError.message,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ErrorPopup({ type = 'full' }: Props) {
|
|
||||||
const error = useRouteError();
|
|
||||||
const routing = isRouteErrorResponse(error);
|
|
||||||
const { title, message } = getErrorMessage(error);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center justify-center',
|
|
||||||
type === 'embedded'
|
|
||||||
? 'pointer-events-none mt-24'
|
|
||||||
: 'fixed inset-0 h-screen w-screen z-50',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Card>
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
|
||||||
<div className="flex justify-between items-center gap-2 w-full">
|
|
||||||
<Card.Title className="text-3xl mb-0">{title}</Card.Title>
|
|
||||||
{routing && <Code className="text-2xl">{`${error.status}`}</Code>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<hr className="my-4 text-headplane-100 dark:text-headplane-800" />
|
|
||||||
<Card.Text
|
|
||||||
className={cn('py-4 text-lg', routing ? 'font-normal' : 'font-mono')}
|
|
||||||
>
|
|
||||||
{routing ? error.data : message}
|
|
||||||
</Card.Text>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -8,11 +8,18 @@ export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
|
|||||||
labelHidden?: boolean;
|
labelHidden?: boolean;
|
||||||
isRequired?: boolean;
|
isRequired?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
isInvalid?: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Custom isInvalid logic for custom error messages
|
|
||||||
export default function Input(props: InputProps) {
|
export default function Input(props: InputProps) {
|
||||||
const { label, labelHidden, className } = props;
|
const {
|
||||||
|
label,
|
||||||
|
labelHidden,
|
||||||
|
className,
|
||||||
|
isInvalid: customIsInvalid,
|
||||||
|
errorMessage,
|
||||||
|
} = props;
|
||||||
const ref = useRef<HTMLInputElement | null>(null);
|
const ref = useRef<HTMLInputElement | null>(null);
|
||||||
const id = useId(props.id);
|
const id = useId(props.id);
|
||||||
|
|
||||||
@@ -21,7 +28,7 @@ export default function Input(props: InputProps) {
|
|||||||
inputProps,
|
inputProps,
|
||||||
descriptionProps,
|
descriptionProps,
|
||||||
errorMessageProps,
|
errorMessageProps,
|
||||||
isInvalid,
|
isInvalid: ariaIsInvalid,
|
||||||
validationErrors,
|
validationErrors,
|
||||||
} = useTextField(
|
} = useTextField(
|
||||||
{
|
{
|
||||||
@@ -32,16 +39,18 @@ export default function Input(props: InputProps) {
|
|||||||
ref,
|
ref,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isInvalid = customIsInvalid ?? ariaIsInvalid;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-full" aria-label={label}>
|
<div className="flex flex-col w-full">
|
||||||
<label
|
<label
|
||||||
{...labelProps}
|
{...labelProps}
|
||||||
htmlFor={id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs font-medium px-3 mb-0.5',
|
'text-xs font-medium px-3 mb-0.5',
|
||||||
'text-headplane-700 dark:text-headplane-100',
|
'text-headplane-700 dark:text-headplane-100',
|
||||||
labelHidden && 'sr-only',
|
labelHidden && 'sr-only',
|
||||||
)}
|
)}
|
||||||
|
htmlFor={id}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
{props.isRequired && (
|
{props.isRequired && (
|
||||||
@@ -50,8 +59,6 @@ export default function Input(props: InputProps) {
|
|||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
required={props.isRequired}
|
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-xl px-3 py-2',
|
'rounded-xl px-3 py-2',
|
||||||
'focus:outline-hidden focus:ring-3',
|
'focus:outline-hidden focus:ring-3',
|
||||||
@@ -59,6 +66,8 @@ export default function Input(props: InputProps) {
|
|||||||
'border border-headplane-100 dark:border-headplane-800',
|
'border border-headplane-100 dark:border-headplane-800',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
ref={ref}
|
||||||
|
required={props.isRequired}
|
||||||
/>
|
/>
|
||||||
{props.description && (
|
{props.description && (
|
||||||
<div
|
<div
|
||||||
@@ -76,7 +85,7 @@ export default function Input(props: InputProps) {
|
|||||||
{...errorMessageProps}
|
{...errorMessageProps}
|
||||||
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
|
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
|
||||||
>
|
>
|
||||||
{validationErrors.join(' ')}
|
{errorMessage ?? validationErrors.join(' ')}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+14
-14
@@ -50,11 +50,11 @@ function Select(props: SelectProps) {
|
|||||||
<div className={cn('flex flex-col', props.className)}>
|
<div className={cn('flex flex-col', props.className)}>
|
||||||
<label
|
<label
|
||||||
{...labelProps}
|
{...labelProps}
|
||||||
htmlFor={id}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-xs font-medium px-3 mb-0.5',
|
'text-xs font-medium px-3 mb-0.5',
|
||||||
'text-headplane-700 dark:text-headplane-100',
|
'text-headplane-700 dark:text-headplane-100',
|
||||||
)}
|
)}
|
||||||
|
htmlFor={id}
|
||||||
>
|
>
|
||||||
{props.label}
|
{props.label}
|
||||||
</label>
|
</label>
|
||||||
@@ -63,18 +63,18 @@ function Select(props: SelectProps) {
|
|||||||
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
|
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
|
||||||
'bg-white dark:bg-headplane-900',
|
'bg-white dark:bg-headplane-900',
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
'border border-headplane-100 dark:border-headplane-800',
|
||||||
|
props.isInvalid && 'ring-red-400',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
{...inputProps}
|
{...inputProps}
|
||||||
ref={inputRef}
|
|
||||||
id={id}
|
|
||||||
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
|
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
|
||||||
data-1p-ignore
|
data-1p-ignore
|
||||||
|
id={id}
|
||||||
|
ref={inputRef}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
{...buttonProps}
|
{...buttonProps}
|
||||||
ref={buttonRef}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center p-1 rounded-lg m-1',
|
'flex items-center justify-center p-1 rounded-lg m-1',
|
||||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||||
@@ -82,6 +82,7 @@ function Select(props: SelectProps) {
|
|||||||
? 'opacity-50 cursor-not-allowed'
|
? 'opacity-50 cursor-not-allowed'
|
||||||
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||||
)}
|
)}
|
||||||
|
ref={buttonRef}
|
||||||
>
|
>
|
||||||
<ChevronDown className="p-0.5" />
|
<ChevronDown className="p-0.5" />
|
||||||
</button>
|
</button>
|
||||||
@@ -99,12 +100,12 @@ function Select(props: SelectProps) {
|
|||||||
)}
|
)}
|
||||||
{state.isOpen && (
|
{state.isOpen && (
|
||||||
<Popover
|
<Popover
|
||||||
popoverRef={popoverRef}
|
className="w-full max-w-xs"
|
||||||
triggerRef={inputRef}
|
|
||||||
state={state}
|
|
||||||
isNonModal
|
isNonModal
|
||||||
placement="bottom start"
|
placement="bottom start"
|
||||||
className="w-full max-w-xs"
|
popoverRef={popoverRef}
|
||||||
|
state={state}
|
||||||
|
triggerRef={inputRef}
|
||||||
>
|
>
|
||||||
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
|
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -114,23 +115,22 @@ function Select(props: SelectProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ListBoxProps extends AriaListBoxOptions<object> {
|
interface ListBoxProps extends AriaListBoxOptions<object> {
|
||||||
listBoxRef?: React.RefObject<HTMLUListElement | null>;
|
listBoxRef: React.RefObject<HTMLUListElement | null>;
|
||||||
state: ListState<object>;
|
state: ListState<object>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ListBox(props: ListBoxProps) {
|
function ListBox(props: ListBoxProps) {
|
||||||
const { listBoxRef, state } = props;
|
const { listBoxRef, state } = props;
|
||||||
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null);
|
const { listBoxProps } = useListBox(props, state, listBoxRef);
|
||||||
const { listBoxProps } = useListBox(props, state, ref);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
{...listBoxProps}
|
{...listBoxProps}
|
||||||
ref={listBoxRef}
|
|
||||||
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
|
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
|
||||||
|
ref={listBoxRef}
|
||||||
>
|
>
|
||||||
{[...state.collection].map((item) => (
|
{[...state.collection].map((item) => (
|
||||||
<Option key={item.key} item={item} state={state} />
|
<Option item={item} key={item.key} state={state} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
);
|
);
|
||||||
@@ -154,7 +154,6 @@ function Option({ item, state }: OptionProps) {
|
|||||||
return (
|
return (
|
||||||
<li
|
<li
|
||||||
{...optionProps}
|
{...optionProps}
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between',
|
'flex items-center justify-between',
|
||||||
'py-2 px-3 mx-1 rounded-lg mb-1',
|
'py-2 px-3 mx-1 rounded-lg mb-1',
|
||||||
@@ -164,6 +163,7 @@ function Option({ item, state }: OptionProps) {
|
|||||||
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
|
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
|
||||||
isDisabled && 'text-headplane-300 dark:text-headplane-600',
|
isDisabled && 'text-headplane-300 dark:text-headplane-600',
|
||||||
)}
|
)}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
{item.rendered}
|
{item.rendered}
|
||||||
{isSelected && <Check className="p-0.5" />}
|
{isSelected && <Check className="p-0.5" />}
|
||||||
|
|||||||
@@ -23,15 +23,15 @@ function Tabs({ label, className, ...props }: TabsProps) {
|
|||||||
<div className={cn('flex flex-col', className)}>
|
<div className={cn('flex flex-col', className)}>
|
||||||
<div
|
<div
|
||||||
{...tabListProps}
|
{...tabListProps}
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center rounded-t-xl w-fit',
|
'flex items-center rounded-t-xl w-fit max-w-full overflow-x-auto',
|
||||||
'border-headplane-100 dark:border-headplane-800',
|
'border-headplane-100 dark:border-headplane-800',
|
||||||
'border-t border-x',
|
'border-t border-x',
|
||||||
)}
|
)}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
{[...state.collection].map((item) => (
|
{[...state.collection].map((item) => (
|
||||||
<Tab key={item.key} item={item} state={state} />
|
<Tab item={item} key={item.key} state={state} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<TabsPanel key={state.selectedItem?.key} state={state} />
|
<TabsPanel key={state.selectedItem?.key} state={state} />
|
||||||
@@ -52,7 +52,6 @@ function Tab({ item, state }: TabsTabProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...tabProps}
|
{...tabProps}
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'pl-2 pr-3 py-2.5',
|
'pl-2 pr-3 py-2.5',
|
||||||
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
|
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
|
||||||
@@ -60,6 +59,7 @@ function Tab({ item, state }: TabsTabProps) {
|
|||||||
'border-r border-headplane-100 dark:border-headplane-800',
|
'border-r border-headplane-100 dark:border-headplane-800',
|
||||||
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
|
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
|
||||||
)}
|
)}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
{rendered}
|
{rendered}
|
||||||
</div>
|
</div>
|
||||||
@@ -76,11 +76,11 @@ function TabsPanel({ state, ...props }: TabsPanelProps) {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
{...tabPanelProps}
|
{...tabPanelProps}
|
||||||
ref={ref}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full overflow-clip rounded-b-xl rounded-r-xl',
|
'w-full overflow-clip rounded-b-xl rounded-r-xl',
|
||||||
'border border-headplane-100 dark:border-headplane-800',
|
'border border-headplane-100 dark:border-headplane-800',
|
||||||
)}
|
)}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
{state.selectedItem?.props.children}
|
{state.selectedItem?.props.children}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import { isRouteErrorResponse } from 'react-router';
|
||||||
|
import {
|
||||||
|
isApiError,
|
||||||
|
isConnectionError,
|
||||||
|
} from '~/server/headscale/api/error-client';
|
||||||
|
import cn from '~/utils/cn';
|
||||||
|
import Card from './Card';
|
||||||
|
import Code from './Code';
|
||||||
|
import Link from './Link';
|
||||||
|
|
||||||
|
export function getErrorMessage(error: Error | unknown): {
|
||||||
|
title: string;
|
||||||
|
jsxMessage: React.ReactNode;
|
||||||
|
} {
|
||||||
|
if (isRouteErrorResponse(error)) {
|
||||||
|
if (isApiError(error.data)) {
|
||||||
|
const { statusCode, rawData, data, requestUrl } = error.data;
|
||||||
|
if (statusCode >= 500) {
|
||||||
|
return {
|
||||||
|
title: 'Headscale API Error',
|
||||||
|
jsxMessage: (
|
||||||
|
<>
|
||||||
|
<Card.Text>
|
||||||
|
There was an error communicating with the Headscale API.
|
||||||
|
<br />
|
||||||
|
The server responded with a status code of{' '}
|
||||||
|
<strong>{statusCode}</strong>, indicating a server-side issue.
|
||||||
|
Please check the Headscale server status and try again later.
|
||||||
|
</Card.Text>
|
||||||
|
{(error.data.data != null || error.data.rawData != null) && (
|
||||||
|
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||||
|
{error.data.data != null ? (
|
||||||
|
<code>{JSON.stringify(error.data.data, null, 2)}</code>
|
||||||
|
) : (
|
||||||
|
<code>{error.data.rawData}</code>
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const authError =
|
||||||
|
error.data.statusCode === 401 || error.data.statusCode === 403;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: 'Invalid response from Headscale API',
|
||||||
|
jsxMessage: (
|
||||||
|
<>
|
||||||
|
<Card.Text className="leading-snug">
|
||||||
|
The Headscale API returned an unexpected response.
|
||||||
|
{authError ? (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
The status code indicates an authentication error. Please
|
||||||
|
verify your API key and Headplane configuration.
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{' '}
|
||||||
|
You may be using an unsupported version of Headscale or this
|
||||||
|
may be a bug.
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card.Text>
|
||||||
|
<ul className="list-disc list-inside mt-2">
|
||||||
|
<li>
|
||||||
|
Request URL: <Code>{requestUrl}</Code>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Status Code:{' '}
|
||||||
|
<Code>
|
||||||
|
{/* @ts-expect-error */}
|
||||||
|
{data === null ? (
|
||||||
|
<>
|
||||||
|
{statusCode} {rawData}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{statusCode} {error.statusText}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Code>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<Card.Text className="text-lg font-semibold mt-4">
|
||||||
|
Error Details
|
||||||
|
</Card.Text>
|
||||||
|
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||||
|
<code>{JSON.stringify(error.data, null, 2)}</code>
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isConnectionError(error.data)) {
|
||||||
|
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
|
||||||
|
return {
|
||||||
|
title: 'Cannot connect to Headscale API',
|
||||||
|
jsxMessage: (
|
||||||
|
<>
|
||||||
|
<Card.Text className="leading-snug">
|
||||||
|
Headplane was unable to reach the Headscale API. Please check your
|
||||||
|
network setup and configuration to ensure Headplane is able to
|
||||||
|
connect.
|
||||||
|
</Card.Text>
|
||||||
|
<Card.Text className="text-lg font-semibold mt-4">
|
||||||
|
Error Details
|
||||||
|
</Card.Text>
|
||||||
|
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||||
|
{requestUrl}
|
||||||
|
<br />
|
||||||
|
{errorCode}: {errorMessage}
|
||||||
|
{extraData != null && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<br />
|
||||||
|
<code>{JSON.stringify(extraData, null, 2)}</code>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `Error ${error.status}`,
|
||||||
|
jsxMessage: (
|
||||||
|
<>
|
||||||
|
There was an error processing your request.
|
||||||
|
<br />
|
||||||
|
Status Code: <strong>{error.status}</strong>
|
||||||
|
<br />
|
||||||
|
Status Text: <strong>{error.data}</strong>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(error instanceof Error)) {
|
||||||
|
return {
|
||||||
|
title: 'Unexpected Error',
|
||||||
|
jsxMessage: (
|
||||||
|
<>
|
||||||
|
<Card.Text>
|
||||||
|
An unexpected error occurred which is most likely a bug. Please
|
||||||
|
consider reporting filing an issue on the{' '}
|
||||||
|
<Link
|
||||||
|
name="Headplane GitHub"
|
||||||
|
to="https://github.com/tale/headplane/issues"
|
||||||
|
>
|
||||||
|
Headplane GitHub
|
||||||
|
</Link>{' '}
|
||||||
|
repository with the details below.
|
||||||
|
</Card.Text>
|
||||||
|
<Card.Text className="text-lg font-semibold mt-4">
|
||||||
|
Error Details
|
||||||
|
</Card.Text>
|
||||||
|
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||||
|
<code>{JSON.stringify(error, null, 2)}</code>
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Traverse the error chain to find the root cause
|
||||||
|
let rootError = error;
|
||||||
|
if (error.cause != null) {
|
||||||
|
rootError = error.cause as Error;
|
||||||
|
while (rootError.cause != null) {
|
||||||
|
rootError = rootError.cause as Error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: If we are aggregate, concat into a single message
|
||||||
|
if (rootError instanceof AggregateError) {
|
||||||
|
throw new Error('AggregateError handling not implemented yet');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title:
|
||||||
|
rootError.name.length > 0 && rootError.name !== 'Error'
|
||||||
|
? `Error: ${rootError.name}`
|
||||||
|
: 'Error',
|
||||||
|
jsxMessage: rootError.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBannerProps {
|
||||||
|
error: unknown;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ErrorBanner({ error, className }: ErrorBannerProps) {
|
||||||
|
const { title, jsxMessage } = getErrorMessage(error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className={cn('w-screen', className)} variant="flat">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<Card.Title>{title}</Card.Title>
|
||||||
|
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
||||||
|
</div>
|
||||||
|
{jsxMessage}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,12 +2,6 @@ import { StrictMode, startTransition } from 'react';
|
|||||||
import { hydrateRoot } from 'react-dom/client';
|
import { hydrateRoot } from 'react-dom/client';
|
||||||
import { HydratedRouter } from 'react-router/dom';
|
import { HydratedRouter } from 'react-router/dom';
|
||||||
|
|
||||||
if (import.meta.env.DEV) {
|
|
||||||
import('react-scan').then(({ scan }) => {
|
|
||||||
scan({ enabled: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
startTransition(() => {
|
startTransition(() => {
|
||||||
hydrateRoot(
|
hydrateRoot(
|
||||||
document,
|
document,
|
||||||
|
|||||||
+54
-55
@@ -1,66 +1,65 @@
|
|||||||
import { PassThrough } from 'node:stream';
|
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||||
import { createReadableStreamFromReadable } from '@react-router/node';
|
import type { AppLoadContext, EntryContext } from "react-router";
|
||||||
import { isbot } from 'isbot';
|
|
||||||
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
|
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||||
import { renderToPipeableStream } from 'react-dom/server';
|
import { isbot } from "isbot";
|
||||||
import { AppLoadContext, EntryContext, ServerRouter } from 'react-router';
|
import { PassThrough } from "node:stream";
|
||||||
|
import { renderToPipeableStream } from "react-dom/server";
|
||||||
|
import { ServerRouter } from "react-router";
|
||||||
|
|
||||||
export const streamTimeout = 5_000;
|
export const streamTimeout = 5_000;
|
||||||
export default function handleRequest(
|
export default function handleRequest(
|
||||||
request: Request,
|
request: Request,
|
||||||
responseStatusCode: number,
|
responseStatusCode: number,
|
||||||
responseHeaders: Headers,
|
responseHeaders: Headers,
|
||||||
routerContext: EntryContext,
|
routerContext: EntryContext,
|
||||||
loadContext: AppLoadContext,
|
_loadContext: AppLoadContext,
|
||||||
) {
|
) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let shellRendered = false;
|
let shellRendered = false;
|
||||||
const userAgent = request.headers.get('user-agent');
|
const userAgent = request.headers.get("user-agent");
|
||||||
|
|
||||||
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
|
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
|
||||||
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
|
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
|
||||||
const readyOption: keyof RenderToPipeableStreamOptions =
|
const readyOption: keyof RenderToPipeableStreamOptions =
|
||||||
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
|
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? "onAllReady" : "onShellReady";
|
||||||
? 'onAllReady'
|
|
||||||
: 'onShellReady';
|
|
||||||
|
|
||||||
const { pipe, abort } = renderToPipeableStream(
|
const { pipe, abort } = renderToPipeableStream(
|
||||||
<ServerRouter context={routerContext} url={request.url} />,
|
<ServerRouter context={routerContext} url={request.url} />,
|
||||||
{
|
{
|
||||||
[readyOption]() {
|
[readyOption]() {
|
||||||
shellRendered = true;
|
shellRendered = true;
|
||||||
const body = new PassThrough();
|
const body = new PassThrough();
|
||||||
const stream = createReadableStreamFromReadable(body);
|
const stream = createReadableStreamFromReadable(body);
|
||||||
|
|
||||||
responseHeaders.set('Content-Type', 'text/html');
|
responseHeaders.set("Content-Type", "text/html");
|
||||||
|
|
||||||
resolve(
|
resolve(
|
||||||
new Response(stream, {
|
new Response(stream, {
|
||||||
headers: responseHeaders,
|
headers: responseHeaders,
|
||||||
status: responseStatusCode,
|
status: responseStatusCode,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
pipe(body);
|
pipe(body);
|
||||||
},
|
},
|
||||||
onShellError(error: unknown) {
|
onShellError(error: unknown) {
|
||||||
reject(error);
|
reject(error);
|
||||||
},
|
},
|
||||||
onError(error: unknown) {
|
onError(error: unknown) {
|
||||||
// biome-ignore lint/style/noParameterAssign: Lazy
|
responseStatusCode = 500;
|
||||||
responseStatusCode = 500;
|
// Log streaming rendering errors from inside the shell. Don't log
|
||||||
// Log streaming rendering errors from inside the shell. Don't log
|
// errors encountered during initial shell rendering since they'll
|
||||||
// errors encountered during initial shell rendering since they'll
|
// reject and get logged in handleDocumentRequest.
|
||||||
// reject and get logged in handleDocumentRequest.
|
if (shellRendered) {
|
||||||
if (shellRendered) {
|
console.error(error);
|
||||||
console.error(error);
|
}
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
},
|
);
|
||||||
);
|
|
||||||
|
|
||||||
// Abort the rendering stream after the `streamTimeout` so it has tine to
|
// Abort the rendering stream after the `streamTimeout` so it has tine to
|
||||||
// flush down the rejected boundaries
|
// flush down the rejected boundaries
|
||||||
setTimeout(abort, streamTimeout + 1000);
|
setTimeout(abort, streamTimeout + 1000);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-40
@@ -1,49 +1,54 @@
|
|||||||
import { type LoaderFunctionArgs, Outlet, redirect } from 'react-router';
|
import { Outlet, redirect } from "react-router";
|
||||||
import { ErrorPopup } from '~/components/Error';
|
|
||||||
import type { LoadContext } from '~/server';
|
|
||||||
import { pruneEphemeralNodes } from '~/server/db/pruner';
|
|
||||||
import ResponseError from '~/server/headscale/api-error';
|
|
||||||
import log from '~/utils/log';
|
|
||||||
|
|
||||||
export async function loader({
|
import { ErrorBanner } from "~/components/error-banner";
|
||||||
request,
|
import { pruneEphemeralNodes } from "~/server/db/pruner";
|
||||||
context,
|
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
||||||
...rest
|
import log from "~/utils/log";
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
|
||||||
const healthy = await context.client.healthcheck();
|
|
||||||
const session = await context.sessions.auth(request);
|
|
||||||
await pruneEphemeralNodes({ context, request, ...rest });
|
|
||||||
|
|
||||||
// We shouldn't session invalidate if Headscale is down
|
import type { Route } from "./+types/dashboard";
|
||||||
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
|
|
||||||
if (healthy) {
|
|
||||||
try {
|
|
||||||
await context.client.get('v1/apikey', session.api_key);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ResponseError) {
|
|
||||||
log.debug('api', 'API Key validation failed %o', error);
|
|
||||||
return redirect('/login', {
|
|
||||||
headers: {
|
|
||||||
'Set-Cookie': await context.sessions.destroySession(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
||||||
healthy,
|
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)) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
<main className="container mx-auto mt-4 mb-24 overscroll-contain">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
return <ErrorPopup type="embedded" />;
|
return (
|
||||||
|
<div className="mx-auto my-24 w-fit overscroll-contain">
|
||||||
|
<ErrorBanner className="max-w-2xl" error={error} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+297
-109
@@ -1,119 +1,307 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { Icon } from "@iconify/react";
|
||||||
import { CircleCheckIcon } from 'lucide-react';
|
import { Form, Outlet, redirect } from "react-router";
|
||||||
import {
|
|
||||||
LoaderFunctionArgs,
|
import Button from "~/components/Button";
|
||||||
Outlet,
|
import Card from "~/components/Card";
|
||||||
redirect,
|
import Footer from "~/components/Footer";
|
||||||
useLoaderData,
|
import Header from "~/components/Header";
|
||||||
} from 'react-router';
|
import Link from "~/components/Link";
|
||||||
import Button from '~/components/Button';
|
import Options from "~/components/Options";
|
||||||
import Card from '~/components/Card';
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import Footer from '~/components/Footer';
|
import toast from "~/utils/toast";
|
||||||
import Header from '~/components/Header';
|
import { getUserDisplayName } from "~/utils/user";
|
||||||
import type { LoadContext } from '~/server';
|
|
||||||
import { users } from '~/server/db/schema';
|
import { Route } from "./+types/shell";
|
||||||
import { Capabilities } from '~/server/web/roles';
|
|
||||||
import toast from '~/utils/toast';
|
|
||||||
|
|
||||||
// This loads the bare minimum for the application to function
|
// This loads the bare minimum for the application to function
|
||||||
// So we know that if context fails to load then well, oops?
|
// So we know that if context fails to load then well, oops?
|
||||||
export async function loader({
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
request,
|
try {
|
||||||
context,
|
const principal = await context.auth.require(request);
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
|
||||||
try {
|
|
||||||
const session = await context.sessions.auth(request);
|
|
||||||
if (
|
|
||||||
context.oidc &&
|
|
||||||
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);
|
|
||||||
|
|
||||||
if (!user?.onboarded) {
|
if (
|
||||||
return redirect('/onboarding');
|
typeof context.oidc === "object" &&
|
||||||
}
|
principal.kind === "oidc" &&
|
||||||
}
|
!principal.user.onboarded &&
|
||||||
|
!request.url.endsWith("/onboarding")
|
||||||
|
) {
|
||||||
|
return redirect("/onboarding");
|
||||||
|
}
|
||||||
|
|
||||||
const check = await context.sessions.check(request, Capabilities.ui_access);
|
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||||
return {
|
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||||
config: context.hs.c,
|
const check = context.auth.can(principal, Capabilities.ui_access);
|
||||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
const noAccess = !check && principal.kind === "oidc";
|
||||||
configAvailable: context.hs.readable(),
|
|
||||||
debug: context.config.debug,
|
const user =
|
||||||
user: session.user,
|
principal.kind === "oidc"
|
||||||
uiAccess: check,
|
? {
|
||||||
access: {
|
subject: principal.user.subject,
|
||||||
ui: await context.sessions.check(request, Capabilities.ui_access),
|
name: principal.profile.name,
|
||||||
dns: await context.sessions.check(request, Capabilities.read_network),
|
email: principal.profile.email,
|
||||||
users: await context.sessions.check(request, Capabilities.read_users),
|
username: principal.profile.username,
|
||||||
policy: await context.sessions.check(request, Capabilities.read_policy),
|
picture: principal.profile.picture,
|
||||||
machines: await context.sessions.check(
|
}
|
||||||
request,
|
: { subject: "api_key", name: principal.displayName };
|
||||||
Capabilities.read_machines,
|
|
||||||
),
|
let linkedUserName: string | undefined;
|
||||||
settings: await context.sessions.check(
|
let osValue: string | undefined;
|
||||||
request,
|
|
||||||
Capabilities.read_feature,
|
if (noAccess && principal.kind === "oidc") {
|
||||||
),
|
const hsUserId = principal.user.headscaleUserId;
|
||||||
},
|
if (hsUserId) {
|
||||||
onboarding: request.url.endsWith('/onboarding'),
|
try {
|
||||||
healthy: await context.client.healthcheck(),
|
const apiUsers = await api.getUsers();
|
||||||
};
|
const hsUser = apiUsers.find((u) => u.id === hsUserId);
|
||||||
} catch {
|
linkedUserName = hsUser ? getUserDisplayName(hsUser) : undefined;
|
||||||
return redirect('/login', {
|
} catch {
|
||||||
headers: {
|
// API unavailable, skip linked user resolution
|
||||||
'Set-Cookie': await context.sessions.destroySession(),
|
}
|
||||||
},
|
}
|
||||||
});
|
|
||||||
}
|
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() {
|
export default function Shell({ loaderData }: Route.ComponentProps) {
|
||||||
const data = useLoaderData<typeof loader>();
|
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>
|
||||||
|
|
||||||
return (
|
<Options
|
||||||
<>
|
className="my-4"
|
||||||
<Header {...data} />
|
defaultSelectedKey={loaderData.osValue ?? "linux"}
|
||||||
{/* Always show the outlet if we are onboarding */}
|
label="Download Selector"
|
||||||
{(data.onboarding ? true : data.uiAccess) ? (
|
>
|
||||||
<Outlet />
|
<Options.Item
|
||||||
) : (
|
key="linux"
|
||||||
<Card className="mx-auto w-fit mt-24">
|
title={
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-1">
|
||||||
<Card.Title className="text-3xl mb-0">Connected</Card.Title>
|
<Icon className="ml-1 w-4" icon="ion:terminal" />
|
||||||
<CircleCheckIcon className="w-10 h-10" />
|
<span>Linux</span>
|
||||||
</div>
|
</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:
|
<Button
|
||||||
</Card.Text>
|
className="text-md flex font-mono"
|
||||||
<Button
|
onPress={async () => {
|
||||||
className="flex text-md font-mono"
|
await navigator.clipboard.writeText(
|
||||||
onPress={async () => {
|
"curl -fsSL https://tailscale.com/install.sh | sh",
|
||||||
await navigator.clipboard.writeText(
|
);
|
||||||
`tailscale up --login-server=${data.url}`,
|
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} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
toast('Copied to clipboard');
|
return (
|
||||||
}}
|
<>
|
||||||
>
|
<Header {...loaderData} />
|
||||||
tailscale up --login-server={data.url}
|
<Outlet />
|
||||||
</Button>
|
<Footer {...loaderData} />
|
||||||
<p className="text-xs mt-1 opacity-50 text-center">
|
</>
|
||||||
Click this button to copy the command.
|
);
|
||||||
</p>
|
|
||||||
<p className="mt-4 text-sm opacity-50">
|
|
||||||
Your account does not have access to the UI. Please contact your
|
|
||||||
administrator if you believe this is a mistake.
|
|
||||||
</p>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
<Footer {...data} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"0.26.1": ["0.26.0", "0.26.1"],
|
||||||
|
"0.27.0": ["0.27.0"],
|
||||||
|
"0.27.1": ["0.27.1"],
|
||||||
|
"0.28.0": ["0.28.0"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
{
|
||||||
|
"0.26.0": {
|
||||||
|
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||||
|
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||||
|
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||||
|
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||||
|
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||||
|
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||||
|
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||||
|
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||||
|
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||||
|
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||||
|
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||||
|
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||||
|
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||||
|
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||||
|
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||||
|
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||||
|
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||||
|
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||||
|
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||||
|
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||||
|
"GET /api/v1/user": "228831b58ccc5a17",
|
||||||
|
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||||
|
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||||
|
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||||
|
},
|
||||||
|
"0.26.1": {
|
||||||
|
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||||
|
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||||
|
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||||
|
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||||
|
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||||
|
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||||
|
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||||
|
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||||
|
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||||
|
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||||
|
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||||
|
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||||
|
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||||
|
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||||
|
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||||
|
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||||
|
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||||
|
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||||
|
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||||
|
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||||
|
"GET /api/v1/user": "228831b58ccc5a17",
|
||||||
|
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||||
|
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||||
|
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||||
|
},
|
||||||
|
"0.27.0": {
|
||||||
|
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||||
|
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||||
|
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||||
|
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||||
|
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||||
|
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||||
|
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||||
|
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||||
|
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||||
|
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||||
|
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||||
|
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||||
|
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||||
|
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||||
|
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||||
|
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||||
|
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||||
|
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||||
|
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||||
|
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||||
|
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||||
|
"GET /api/v1/user": "228831b58ccc5a17",
|
||||||
|
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||||
|
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||||
|
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||||
|
},
|
||||||
|
"0.27.1": {
|
||||||
|
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||||
|
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||||
|
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||||
|
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||||
|
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||||
|
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||||
|
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||||
|
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||||
|
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||||
|
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||||
|
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||||
|
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||||
|
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
||||||
|
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||||
|
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||||
|
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||||
|
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||||
|
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||||
|
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||||
|
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||||
|
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||||
|
"GET /api/v1/user": "228831b58ccc5a17",
|
||||||
|
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||||
|
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||||
|
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||||
|
},
|
||||||
|
"0.28.0": {
|
||||||
|
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||||
|
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||||
|
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||||
|
"DELETE /api/v1/apikey/{prefix}": "b10ca7d2750405b2",
|
||||||
|
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||||
|
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||||
|
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||||
|
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||||
|
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||||
|
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||||
|
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||||
|
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||||
|
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
||||||
|
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||||
|
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||||
|
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||||
|
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||||
|
"GET /api/v1/preauthkey": "8428b44e3a821e9e",
|
||||||
|
"DELETE /api/v1/preauthkey": "f05ea1bc8ad89a09",
|
||||||
|
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||||
|
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||||
|
"GET /api/v1/user": "228831b58ccc5a17",
|
||||||
|
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||||
|
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||||
|
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||||
|
}
|
||||||
|
}
|
||||||
+57
-58
@@ -1,72 +1,71 @@
|
|||||||
import type { LinksFunction, MetaFunction } from 'react-router';
|
import type { LinksFunction, MetaFunction } from "react-router";
|
||||||
import {
|
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from "react-router";
|
||||||
Links,
|
import "@fontsource-variable/inter";
|
||||||
Meta,
|
import { ExternalScripts } from "remix-utils/external-scripts";
|
||||||
Outlet,
|
|
||||||
Scripts,
|
import ProgressBar from "~/components/ProgressBar";
|
||||||
ScrollRestoration,
|
import ToastProvider from "~/components/ToastProvider";
|
||||||
useNavigation,
|
import { LiveDataProvider } from "~/utils/live-data";
|
||||||
} from 'react-router';
|
import { useToastQueue } from "~/utils/toast";
|
||||||
import '@fontsource-variable/inter';
|
|
||||||
import { ExternalScripts } from 'remix-utils/external-scripts';
|
import type { Route } from "./+types/root";
|
||||||
import { ErrorPopup } from '~/components/Error';
|
import { ErrorBanner } from "./components/error-banner";
|
||||||
import ProgressBar from '~/components/ProgressBar';
|
|
||||||
import ToastProvider from '~/components/ToastProvider';
|
import stylesheet from "~/tailwind.css?url";
|
||||||
import stylesheet from '~/tailwind.css?url';
|
|
||||||
import { LiveDataProvider } from '~/utils/live-data';
|
|
||||||
import { useToastQueue } from '~/utils/toast';
|
|
||||||
|
|
||||||
export const meta: MetaFunction = () => [
|
export const meta: MetaFunction = () => [
|
||||||
{ title: 'Headplane' },
|
{ title: "Headplane" },
|
||||||
{
|
{
|
||||||
name: 'description',
|
name: "description",
|
||||||
content: 'A frontend for the headscale coordination server',
|
content: "A frontend for the headscale coordination server",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const links: LinksFunction = () => [
|
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
|
||||||
{ rel: 'stylesheet', href: stylesheet },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
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
|
// LiveDataProvider is wrapped at the top level since dialogs and things
|
||||||
// that control its state are usually open in portal containers which
|
// that control its state are usually open in portal containers which
|
||||||
// are not a part of the normal React tree.
|
// are not a part of the normal React tree.
|
||||||
return (
|
return (
|
||||||
<LiveDataProvider>
|
<LiveDataProvider>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charSet="utf-8" />
|
<meta charSet="utf-8" />
|
||||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||||
<Meta />
|
<Meta />
|
||||||
<Links />
|
<Links />
|
||||||
<link href="favicon.ico" rel="icon" />
|
<link href={`${__PREFIX__}/favicon.ico`} rel="icon" />
|
||||||
</head>
|
</head>
|
||||||
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
|
<body className="dark:bg-headplane-900 dark:text-headplane-50 overflow-x-hidden overscroll-none">
|
||||||
{children}
|
{children}
|
||||||
<ToastProvider queue={toastQueue} />
|
<ToastProvider queue={toastQueue} />
|
||||||
<ScrollRestoration />
|
<ScrollRestoration />
|
||||||
<Scripts />
|
<Scripts />
|
||||||
<ExternalScripts />
|
<ExternalScripts />
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
</LiveDataProvider>
|
</LiveDataProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ErrorBoundary() {
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
return <ErrorPopup />;
|
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() {
|
export default function App() {
|
||||||
const nav = useNavigation();
|
const nav = useNavigation();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ProgressBar isVisible={nav.state === 'loading'} />
|
<ProgressBar isVisible={nav.state === "loading"} />
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+34
-31
@@ -1,38 +1,41 @@
|
|||||||
import { index, layout, prefix, route } from '@react-router/dev/routes';
|
import { index, layout, prefix, route } from "@react-router/dev/routes";
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
// Utility Routes
|
// Utility Routes
|
||||||
index('routes/util/redirect.ts'),
|
index("routes/util/redirect.ts"),
|
||||||
route('/healthz', 'routes/util/healthz.ts'),
|
route("/healthz", "routes/util/healthz.ts"),
|
||||||
|
|
||||||
// Authentication Routes
|
// API Routes
|
||||||
route('/login', 'routes/auth/login/page.tsx'),
|
...prefix("/api", [route("/info", "routes/util/info.ts")]),
|
||||||
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
|
// Authentication Routes
|
||||||
// Double nested to separate error propagations
|
route("/login", "routes/auth/login/page.tsx"),
|
||||||
layout('layouts/shell.tsx', [
|
route("/logout", "routes/auth/logout.ts"),
|
||||||
route('/onboarding', 'routes/users/onboarding.tsx'),
|
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
|
||||||
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'),
|
route("/oidc/start", "routes/auth/oidc-start.ts"),
|
||||||
layout('layouts/dashboard.tsx', [
|
route("/ssh", "routes/ssh/console.tsx"),
|
||||||
...prefix('/machines', [
|
|
||||||
index('routes/machines/overview.tsx'),
|
|
||||||
route('/:id', 'routes/machines/machine.tsx'),
|
|
||||||
]),
|
|
||||||
|
|
||||||
route('/users', 'routes/users/overview.tsx'),
|
// All the main logged-in dashboard routes
|
||||||
route('/acls', 'routes/acls/overview.tsx'),
|
// Double nested to separate error propagations
|
||||||
route('/dns', 'routes/dns/overview.tsx'),
|
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"),
|
||||||
|
]),
|
||||||
|
|
||||||
...prefix('/settings', [
|
route("/users", "routes/users/overview.tsx"),
|
||||||
index('routes/settings/overview.tsx'),
|
route("/acls", "routes/acls/overview.tsx"),
|
||||||
route('/auth-keys', 'routes/settings/auth-keys/overview.tsx'),
|
route("/dns", "routes/dns/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'),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
|
]),
|
||||||
];
|
];
|
||||||
|
|||||||
+115
-99
@@ -1,113 +1,129 @@
|
|||||||
import { ActionFunctionArgs, data } from 'react-router';
|
import { data } from "react-router";
|
||||||
import { LoadContext } from '~/server';
|
|
||||||
import ResponseError from '~/server/headscale/api-error';
|
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||||
import { Capabilities } from '~/server/web/roles';
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import { data400, data403 } from '~/utils/res';
|
|
||||||
|
import type { Route } from "./+types/overview";
|
||||||
|
|
||||||
// We only check capabilities here and assume it is writable
|
// We only check capabilities here and assume it is writable
|
||||||
// If it isn't, it'll gracefully error anyways, since this means some
|
// If it isn't, it'll gracefully error anyways, since this means some
|
||||||
// fishy client manipulation is happening.
|
// fishy client manipulation is happening.
|
||||||
export async function aclAction({
|
export async function aclAction({ request, context }: Route.ActionArgs) {
|
||||||
request,
|
const principal = await context.auth.require(request);
|
||||||
context,
|
const check = context.auth.can(principal, Capabilities.write_policy);
|
||||||
}: ActionFunctionArgs<LoadContext>) {
|
if (!check) {
|
||||||
const session = await context.sessions.auth(request);
|
throw data("You do not have permission to write to the ACL policy", {
|
||||||
const check = await context.sessions.check(
|
status: 403,
|
||||||
request,
|
});
|
||||||
Capabilities.write_policy,
|
}
|
||||||
);
|
|
||||||
if (!check) {
|
|
||||||
throw data403('You do not have permission to write to the ACL policy');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to write to the ACL policy via the API or via config file (TODO).
|
// Try to write to the ACL policy via the API or via config file (TODO).
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const policyData = formData.get('policy')?.toString();
|
const policyData = formData.get("policy")?.toString();
|
||||||
if (!policyData) {
|
if (!policyData) {
|
||||||
throw data400('Missing `policy` in the form data.');
|
throw data("Missing `policy` in the form data.", {
|
||||||
}
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||||
const { policy, updatedAt } = await context.client.put<{
|
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||||
policy: string;
|
try {
|
||||||
updatedAt: string;
|
const { policy, updatedAt } = await api.setPolicy(policyData);
|
||||||
}>('v1/policy', session.api_key, {
|
return data({
|
||||||
policy: policyData,
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
return data({
|
const message =
|
||||||
success: true,
|
error.data.data != null &&
|
||||||
error: undefined,
|
"message" in error.data.data &&
|
||||||
policy,
|
typeof error.data.data.message === "string"
|
||||||
updatedAt,
|
? error.data.data.message
|
||||||
});
|
: undefined;
|
||||||
} catch (error) {
|
|
||||||
// This means Headscale returned a protobuf error to us
|
|
||||||
// It also means we 100% know this is in database mode
|
|
||||||
if (error instanceof ResponseError && error.responseObject?.message) {
|
|
||||||
const message = error.responseObject.message as string;
|
|
||||||
// This is stupid, refer to the link
|
|
||||||
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
|
|
||||||
if (message.includes('update is disabled')) {
|
|
||||||
// This means the policy is not writable
|
|
||||||
throw data403('Policy is not writable');
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
|
if (message == null) {
|
||||||
if (message.includes('parsing hujson')) {
|
throw error;
|
||||||
// This means the policy was invalid, return a 400
|
}
|
||||||
// with the actual error message from Headscale
|
|
||||||
const cutIndex = message.indexOf('err: hujson:');
|
|
||||||
const trimmed =
|
|
||||||
cutIndex > -1
|
|
||||||
? `Syntax error: ${message.slice(cutIndex + 12)}`
|
|
||||||
: message;
|
|
||||||
|
|
||||||
return data(
|
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
|
||||||
{
|
// we need to reference other error messages based on API version.
|
||||||
success: false,
|
if (context.hsApi.clientHelpers.isAtleast("0.27.0")) {
|
||||||
error: trimmed,
|
if (message.includes("parsing HuJSON:")) {
|
||||||
policy: undefined,
|
const cutIndex = message.indexOf("parsing HuJSON:");
|
||||||
updatedAt: undefined,
|
const trimmed =
|
||||||
},
|
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes('unmarshalling policy')) {
|
return data(
|
||||||
// This means the policy was invalid, return a 400
|
{
|
||||||
// with the actual error message from Headscale
|
success: false,
|
||||||
const cutIndex = message.indexOf('err:');
|
error: trimmed,
|
||||||
const trimmed =
|
policy: undefined,
|
||||||
cutIndex > -1
|
updatedAt: undefined,
|
||||||
? `Syntax error: ${message.slice(cutIndex + 5)}`
|
},
|
||||||
: message;
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return data(
|
if (message.includes("parsing policy from bytes:")) {
|
||||||
{
|
const cutIndex = message.indexOf("parsing policy from bytes:");
|
||||||
success: false,
|
const trimmed =
|
||||||
error: trimmed,
|
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
|
||||||
policy: undefined,
|
|
||||||
updatedAt: undefined,
|
|
||||||
},
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.includes('empty policy')) {
|
return data(
|
||||||
return data(
|
{
|
||||||
{
|
success: false,
|
||||||
success: false,
|
error: trimmed,
|
||||||
error: 'Policy error: Supplied policy was empty',
|
policy: undefined,
|
||||||
policy: undefined,
|
updatedAt: undefined,
|
||||||
updatedAt: undefined,
|
},
|
||||||
},
|
400,
|
||||||
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;
|
||||||
|
|
||||||
// Otherwise, this is a Headscale error that we can just propagate.
|
return data(
|
||||||
throw error;
|
{
|
||||||
}
|
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;
|
||||||
|
|
||||||
|
return data(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: trimmed,
|
||||||
|
policy: undefined,
|
||||||
|
updatedAt: undefined,
|
||||||
|
},
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, this is a Headscale error that we can just propagate.
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { LoaderFunctionArgs } from 'react-router';
|
import { data } from "react-router";
|
||||||
import { LoadContext } from '~/server';
|
|
||||||
import ResponseError from '~/server/headscale/api-error';
|
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||||
import { Capabilities } from '~/server/web/roles';
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import { data403 } from '~/utils/res';
|
|
||||||
|
import type { Route } from "./+types/overview";
|
||||||
|
|
||||||
// The logic for deciding policy factors is very complicated because
|
// The logic for deciding policy factors is very complicated because
|
||||||
// there are so many factors that need to be accounted for:
|
// there are so many factors that need to be accounted for:
|
||||||
@@ -11,54 +12,40 @@ import { data403 } from '~/utils/res';
|
|||||||
// 3. Is the Headscale policy in file or database mode?
|
// 3. Is the Headscale policy in file or database mode?
|
||||||
// If database, we can read/write easily via the API.
|
// If database, we can read/write easily via the API.
|
||||||
// If in file mode, we can only write if context.config is available.
|
// If in file mode, we can only write if context.config is available.
|
||||||
// TODO: Consider adding back file editing mode instead of database
|
export async function aclLoader({ request, context }: Route.LoaderArgs) {
|
||||||
export async function aclLoader({
|
const principal = await context.auth.require(request);
|
||||||
request,
|
const check = context.auth.can(principal, Capabilities.read_policy);
|
||||||
context,
|
if (!check) {
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
throw data("You do not have permission to read the ACL policy.", {
|
||||||
const session = await context.sessions.auth(request);
|
status: 403,
|
||||||
const check = await context.sessions.check(request, Capabilities.read_policy);
|
});
|
||||||
if (!check) {
|
}
|
||||||
throw data403('You do not have permission to read the ACL policy.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const flags = {
|
const flags = {
|
||||||
// Can the user write to the ACL policy
|
// Can the user write to the ACL policy
|
||||||
access: await context.sessions.check(request, Capabilities.write_policy),
|
access: context.auth.can(principal, Capabilities.write_policy),
|
||||||
writable: false,
|
writable: false,
|
||||||
policy: '',
|
policy: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Try to load the ACL policy from the API.
|
// Try to load the ACL policy from the API.
|
||||||
try {
|
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||||
const { policy, updatedAt } = await context.client.get<{
|
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||||
policy: string;
|
try {
|
||||||
updatedAt: string | null;
|
const { policy, updatedAt } = await api.getPolicy();
|
||||||
}>('v1/policy', session.api_key);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Successfully loaded the policy, mark it as readable
|
throw error;
|
||||||
// If `updatedAt` is null, it means the policy is in file mode.
|
}
|
||||||
flags.writable = updatedAt !== null;
|
|
||||||
flags.policy = policy;
|
|
||||||
return flags;
|
|
||||||
} catch (error) {
|
|
||||||
// This means Headscale returned a protobuf error to us
|
|
||||||
// It also means we 100% know this is in database mode
|
|
||||||
if (error instanceof ResponseError && error.responseObject?.message) {
|
|
||||||
const message = error.responseObject.message as string;
|
|
||||||
// This is stupid, refer to the link
|
|
||||||
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
|
|
||||||
if (message.includes('acl policy not found')) {
|
|
||||||
// This means the policy has never been initiated, and we can
|
|
||||||
// write to it to get it started or ignore it.
|
|
||||||
flags.policy = ''; // Start with an empty policy
|
|
||||||
flags.writable = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return flags;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Otherwise, this is a Headscale error that we can just propagate.
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,14 +38,14 @@ export function Editor(props: EditorProps) {
|
|||||||
<ClientOnly fallback={<Fallback acl={props.value} />}>
|
<ClientOnly fallback={<Fallback acl={props.value} />}>
|
||||||
{() => (
|
{() => (
|
||||||
<CodeMirror
|
<CodeMirror
|
||||||
value={props.value}
|
editable={!props.isDisabled}
|
||||||
editable={!props.isDisabled} // Allow editing unless disabled
|
extensions={[shopify.jsonc()]} // Allow editing unless disabled
|
||||||
readOnly={props.isDisabled} // Use readOnly if disabled
|
height="100%" // Use readOnly if disabled
|
||||||
height="100%"
|
onChange={(value) => props.onChange(value)}
|
||||||
extensions={[shopify.jsonc()]}
|
readOnly={props.isDisabled}
|
||||||
style={{ height: '100%' }}
|
style={{ height: '100%' }}
|
||||||
theme={light ? xcodeLight : xcodeDark}
|
theme={light ? xcodeLight : xcodeDark}
|
||||||
onChange={(value) => props.onChange(value)}
|
value={props.value}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ClientOnly>
|
</ClientOnly>
|
||||||
@@ -92,14 +92,14 @@ export function Differ(props: DifferProps) {
|
|||||||
{() => (
|
{() => (
|
||||||
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
|
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
|
||||||
<Merge.Original
|
<Merge.Original
|
||||||
|
extensions={[shopify.jsonc()]}
|
||||||
readOnly
|
readOnly
|
||||||
value={props.left}
|
value={props.left}
|
||||||
extensions={[shopify.jsonc()]}
|
|
||||||
/>
|
/>
|
||||||
<Merge.Modified
|
<Merge.Modified
|
||||||
|
extensions={[shopify.jsonc()]}
|
||||||
readOnly
|
readOnly
|
||||||
value={props.right}
|
value={props.right}
|
||||||
extensions={[shopify.jsonc()]}
|
|
||||||
/>
|
/>
|
||||||
</Merge>
|
</Merge>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -23,12 +23,12 @@ export default function Fallback({ acl }: Props) {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
readOnly
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full h-editor font-mono resize-none text-sm',
|
'w-full h-editor font-mono resize-none text-sm',
|
||||||
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
|
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
|
||||||
'pl-1 pt-1 leading-snug',
|
'pl-1 pt-1 leading-snug',
|
||||||
)}
|
)}
|
||||||
|
readOnly
|
||||||
value={acl}
|
value={acl}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,31 @@
|
|||||||
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import {
|
import {
|
||||||
ActionFunctionArgs,
|
AlertCircle,
|
||||||
LoaderFunctionArgs,
|
Construction,
|
||||||
useFetcher,
|
Eye,
|
||||||
useLoaderData,
|
FlaskConical,
|
||||||
useRevalidator,
|
Pencil,
|
||||||
} from 'react-router';
|
} from 'lucide-react';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { isRouteErrorResponse, useFetcher, useRevalidator } from 'react-router';
|
||||||
import Button from '~/components/Button';
|
import Button from '~/components/Button';
|
||||||
|
import Card from '~/components/Card';
|
||||||
import Code from '~/components/Code';
|
import Code from '~/components/Code';
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
import Notice from '~/components/Notice';
|
import Notice from '~/components/Notice';
|
||||||
import Tabs from '~/components/Tabs';
|
import Tabs from '~/components/Tabs';
|
||||||
import type { LoadContext } from '~/server';
|
import { isApiError } from '~/server/headscale/api/error-client';
|
||||||
import toast from '~/utils/toast';
|
import toast from '~/utils/toast';
|
||||||
|
import type { Route } from './+types/overview';
|
||||||
import { aclAction } from './acl-action';
|
import { aclAction } from './acl-action';
|
||||||
import { aclLoader } from './acl-loader';
|
import { aclLoader } from './acl-loader';
|
||||||
import { Differ, Editor } from './components/cm.client';
|
import { Differ, Editor } from './components/cm.client';
|
||||||
|
|
||||||
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
|
export const loader = aclLoader;
|
||||||
return aclLoader(request);
|
export const action = aclAction;
|
||||||
}
|
|
||||||
|
|
||||||
export async function action(request: ActionFunctionArgs<LoadContext>) {
|
export default function Page({
|
||||||
return aclAction(request);
|
loaderData: { access, writable, policy },
|
||||||
}
|
}: Route.ComponentProps) {
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
// Access is a write check here, we already check read in aclLoader
|
|
||||||
const { access, writable, policy } = useLoaderData<typeof loader>();
|
|
||||||
const [codePolicy, setCodePolicy] = useState(policy);
|
const [codePolicy, setCodePolicy] = useState(policy);
|
||||||
const fetcher = useFetcher<typeof action>();
|
const fetcher = useFetcher<typeof action>();
|
||||||
const { revalidate } = useRevalidator();
|
const { revalidate } = useRevalidator();
|
||||||
@@ -75,15 +72,15 @@ export default function Page() {
|
|||||||
The ACL file is used to define the access control rules for your
|
The ACL file is used to define the access control rules for your
|
||||||
network. You can find more information about the ACL file in the{' '}
|
network. You can find more information about the ACL file in the{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://tailscale.com/kb/1018/acls"
|
|
||||||
name="Tailscale ACL documentation"
|
name="Tailscale ACL documentation"
|
||||||
|
to="https://tailscale.com/kb/1018/acls"
|
||||||
>
|
>
|
||||||
Tailscale ACL guide
|
Tailscale ACL guide
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
and the{' '}
|
and the{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://headscale.net/stable/ref/acls/"
|
|
||||||
name="Headscale ACL documentation"
|
name="Headscale ACL documentation"
|
||||||
|
to="https://headscale.net/stable/ref/acls/"
|
||||||
>
|
>
|
||||||
Headscale docs
|
Headscale docs
|
||||||
</Link>
|
</Link>
|
||||||
@@ -91,14 +88,14 @@ export default function Page() {
|
|||||||
</p>
|
</p>
|
||||||
{fetcher.data?.error !== undefined ? (
|
{fetcher.data?.error !== undefined ? (
|
||||||
<Notice
|
<Notice
|
||||||
variant="error"
|
|
||||||
title={fetcher.data.error.split(':')[0] ?? 'Error'}
|
title={fetcher.data.error.split(':')[0] ?? 'Error'}
|
||||||
|
variant="error"
|
||||||
>
|
>
|
||||||
{fetcher.data.error.split(':').slice(1).join(': ') ??
|
{fetcher.data.error.split(':').slice(1).join(': ') ??
|
||||||
'An unknown error occurred while trying to update the ACL policy.'}
|
'An unknown error occurred while trying to update the ACL policy.'}
|
||||||
</Notice>
|
</Notice>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
<Tabs label="ACL Editor" className="mb-4">
|
<Tabs className="mb-4" label="ACL Editor">
|
||||||
<Tabs.Item
|
<Tabs.Item
|
||||||
key="edit"
|
key="edit"
|
||||||
title={
|
title={
|
||||||
@@ -110,8 +107,8 @@ export default function Page() {
|
|||||||
>
|
>
|
||||||
<Editor
|
<Editor
|
||||||
isDisabled={disabled}
|
isDisabled={disabled}
|
||||||
value={codePolicy}
|
|
||||||
onChange={setCodePolicy}
|
onChange={setCodePolicy}
|
||||||
|
value={codePolicy}
|
||||||
/>
|
/>
|
||||||
</Tabs.Item>
|
</Tabs.Item>
|
||||||
<Tabs.Item
|
<Tabs.Item
|
||||||
@@ -145,7 +142,6 @@ export default function Page() {
|
|||||||
</Tabs.Item>
|
</Tabs.Item>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
<Button
|
<Button
|
||||||
variant="heavy"
|
|
||||||
className="mr-2"
|
className="mr-2"
|
||||||
isDisabled={
|
isDisabled={
|
||||||
disabled ||
|
disabled ||
|
||||||
@@ -158,6 +154,7 @@ export default function Page() {
|
|||||||
formData.append('policy', codePolicy);
|
formData.append('policy', codePolicy);
|
||||||
fetcher.submit(formData, { method: 'PATCH' });
|
fetcher.submit(formData, { method: 'PATCH' });
|
||||||
}}
|
}}
|
||||||
|
variant="heavy"
|
||||||
>
|
>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
@@ -175,3 +172,49 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||||
|
if (
|
||||||
|
isRouteErrorResponse(error) &&
|
||||||
|
isApiError(error.data) &&
|
||||||
|
error.data.rawData.includes('reading policy from path') &&
|
||||||
|
error.data.rawData.includes('no such file or directory')
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<Card className="max-w-2xl" variant="flat">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<Card.Title>ACL Policy Unavailable</Card.Title>
|
||||||
|
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<Card.Text>
|
||||||
|
The ACL policy is currently unavailable because the policy file does
|
||||||
|
not exist on the server. This usually indicates that Headscale is
|
||||||
|
running in <Code>file</Code> mode for ACLs, and the specified policy
|
||||||
|
file is missing.
|
||||||
|
</Card.Text>
|
||||||
|
</Card>
|
||||||
|
<Card className="max-w-2xl" variant="flat">
|
||||||
|
<Card.Text>
|
||||||
|
In order to resolve this issue, there are two possible actions you
|
||||||
|
can take:
|
||||||
|
</Card.Text>
|
||||||
|
<ul className="list-disc list-outside mt-2 ml-4 space-y-1 text-sm">
|
||||||
|
<li>
|
||||||
|
Create the ACL policy file at the specified path in your Headscale
|
||||||
|
configuration.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Alternatively, you can switch Headscale to use{' '}
|
||||||
|
<Code>database</Code> mode for ACLs by updating your Headscale
|
||||||
|
configuration. This will allow Headplane to manage the ACL policy
|
||||||
|
directly through the web interface.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,104 +1,102 @@
|
|||||||
import { ActionFunctionArgs, data, redirect } from 'react-router';
|
import { redirect } from "react-router";
|
||||||
import { LoadContext } from '~/server';
|
|
||||||
import ResponseError from '~/server/headscale/api-error';
|
|
||||||
import { Key } from '~/types';
|
|
||||||
import log from '~/utils/log';
|
|
||||||
|
|
||||||
export async function loginAction({
|
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||||
request,
|
import log from "~/utils/log";
|
||||||
context,
|
|
||||||
}: ActionFunctionArgs<LoadContext>) {
|
|
||||||
const formData = await request.formData();
|
|
||||||
const apiKey = formData.has('api_key')
|
|
||||||
? String(formData.get('api_key'))
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (apiKey === undefined) {
|
import type { Route } from "./+types/page";
|
||||||
log.warn('auth', 'Request made without API key');
|
|
||||||
log.warn(
|
|
||||||
'auth',
|
|
||||||
'If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly',
|
|
||||||
);
|
|
||||||
throw data('Missing `api_key`', { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (apiKey.length === 0) {
|
export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||||
log.warn('auth', 'Request made with empty API key');
|
const formData = await request.formData();
|
||||||
log.warn(
|
const apiKey = formData.has("api_key") ? String(formData.get("api_key")) : undefined;
|
||||||
'auth',
|
|
||||||
'If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly',
|
|
||||||
);
|
|
||||||
throw data('Received an empty `api_key`', { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
if (apiKey === undefined) {
|
||||||
const { apiKeys } = await context.client.get<{ apiKeys: Key[] }>(
|
log.warn("auth", "Request made without API key");
|
||||||
'v1/apikey',
|
log.warn(
|
||||||
apiKey,
|
"auth",
|
||||||
);
|
"If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly",
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Missing API key. Please enter your API key.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// We don't need to check for 0 API keys because this request cannot
|
if (apiKey.length === 0) {
|
||||||
// be authenticated correctly without an API key
|
log.warn("auth", "Request made with empty API key");
|
||||||
const lookup = apiKeys.find((key) => apiKey.startsWith(key.prefix));
|
log.warn(
|
||||||
if (!lookup) {
|
"auth",
|
||||||
return {
|
"If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly",
|
||||||
success: false,
|
);
|
||||||
message: 'API key was not found in the Headscale database',
|
return {
|
||||||
};
|
success: false,
|
||||||
}
|
message: "API key cannot be empty. Please enter a valid API key.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (lookup.expiration === null || lookup.expiration === undefined) {
|
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||||
log.error('auth', 'Got an API key without an expiration');
|
try {
|
||||||
throw data('API key is malformed', { status: 500 });
|
const apiKeys = await api.getApiKeys();
|
||||||
}
|
|
||||||
|
|
||||||
const expiry = new Date(lookup.expiration);
|
// We don't need to check for 0 API keys because this request cannot
|
||||||
if (expiry.getTime() < Date.now()) {
|
// be authenticated correctly without an API key
|
||||||
return {
|
//
|
||||||
success: false,
|
// 0.28.0 pointlessly added asterisks to the prefixes of API keys, which is
|
||||||
message: 'API key has expired',
|
// the dumbest thing I've ever seen.
|
||||||
};
|
const lookup = apiKeys.find((key) => apiKey.startsWith(key.prefix.replaceAll("*", "")));
|
||||||
}
|
if (!lookup) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "API key was not found in the Headscale database",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const expiresDays = Math.round(
|
if (lookup.expiration === null || lookup.expiration === undefined) {
|
||||||
(expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24,
|
log.error("auth", "Got an API key without an expiration");
|
||||||
);
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "API key is malformed (missing expiration). Please generate a new API key.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return redirect('/machines', {
|
const expiry = new Date(lookup.expiration);
|
||||||
headers: {
|
if (expiry.getTime() < Date.now()) {
|
||||||
'Set-Cookie': await context.sessions.createSession(
|
return {
|
||||||
{
|
success: false,
|
||||||
api_key: apiKey,
|
message: "API key has expired",
|
||||||
user: {
|
};
|
||||||
subject: 'unknown-non-oauth',
|
}
|
||||||
name: `${lookup.prefix}...`,
|
|
||||||
email: `expires@${expiresDays.toString()}-days`,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
expiry.getTime() - Date.now(),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ResponseError) {
|
|
||||||
// TODO: What in gods name is wrong with the headscale API?
|
|
||||||
if (
|
|
||||||
error.status === 401 ||
|
|
||||||
error.status === 403 ||
|
|
||||||
(error.status === 500 && error.response.trim() === 'Unauthorized')
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
message: 'API key is invalid (it may be incorrect or expired)',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.error('auth', 'Error while validating API key: %s', error);
|
return redirect("/machines", {
|
||||||
log.debug('auth', 'Error details: %o', error);
|
headers: {
|
||||||
return {
|
"Set-Cookie": await context.auth.createApiKeySession(
|
||||||
success: false,
|
apiKey,
|
||||||
message: 'Error while validating API key (see logs for details)',
|
`${lookup.prefix}...`,
|
||||||
};
|
expiry.getTime() - Date.now(),
|
||||||
}
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Check if this is a React Router DataWithResponseInit wrapping a Headscale API error
|
||||||
|
if (isDataWithApiError(error)) {
|
||||||
|
const apiError = error.data;
|
||||||
|
// TODO: What in gods name is wrong with the headscale API?
|
||||||
|
if (
|
||||||
|
apiError.statusCode === 401 ||
|
||||||
|
apiError.statusCode === 403 ||
|
||||||
|
(apiError.statusCode === 500 && apiError.rawData.trim() === "Unauthorized")
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "API key is invalid (it may be incorrect or expired)",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error("auth", "Error while validating API key: %s", error);
|
||||||
|
log.debug("auth", "Error details: %o", error);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: "Error while validating API key (see logs for details)",
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { AlertCircle, CloudOff } from "lucide-react";
|
||||||
|
|
||||||
|
import Card from "~/components/Card";
|
||||||
|
import Code from "~/components/Code";
|
||||||
|
import Link from "~/components/Link";
|
||||||
|
import { OidcConnectorError } from "~/server/web/oidc-connector";
|
||||||
|
|
||||||
|
export function OidcDiscoveryFailedNotice() {
|
||||||
|
return (
|
||||||
|
<Card className="m-4 mb-4 max-w-md border border-yellow-500 sm:m-0 sm:mb-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<Card.Title className="text-yellow-500">SSO Temporarily Unavailable</Card.Title>
|
||||||
|
<CloudOff className="mb-2 h-6 w-6 text-yellow-500" />
|
||||||
|
</div>
|
||||||
|
<Card.Text className="text-sm">
|
||||||
|
Unable to reach the identity provider. Single Sign-On will be available once the provider is
|
||||||
|
reachable again. You can still sign in with an API key.
|
||||||
|
</Card.Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[] }) {
|
||||||
|
return (
|
||||||
|
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<Card.Title className="text-red-500">Authentication Error</Card.Title>
|
||||||
|
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
|
||||||
|
</div>
|
||||||
|
<Card.Text className="text-sm">
|
||||||
|
The OpenID Connect (OIDC) Single Sign-On (SSO) configuration has issues:{" "}
|
||||||
|
<ul className="mt-2 mb-1 list-inside list-disc">
|
||||||
|
{mapOidcErrorsToMessages(errors).map((code) => (
|
||||||
|
<li key={code.key}>{code.node}</li>
|
||||||
|
))}
|
||||||
|
</ul>{" "}
|
||||||
|
<Link
|
||||||
|
name="Headplane OIDC Issues"
|
||||||
|
to="https://headplane.net/configuration/sso#troubleshooting"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</Card.Text>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
|
||||||
|
const messages: {
|
||||||
|
key: string;
|
||||||
|
node: React.ReactNode;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const error of errors) {
|
||||||
|
switch (error) {
|
||||||
|
case "INVALID_API_KEY":
|
||||||
|
messages.push({
|
||||||
|
key: error,
|
||||||
|
node: (
|
||||||
|
<Card.Text className="inline">
|
||||||
|
The provided API key for OIDC authentication is invalid. Ensure that{" "}
|
||||||
|
<Code>oidc.headscale_api_key</Code> is a valid API key.
|
||||||
|
</Card.Text>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "MISSING_AUTHORIZATION_ENDPOINT":
|
||||||
|
messages.push({
|
||||||
|
key: error,
|
||||||
|
node: (
|
||||||
|
<Card.Text className="inline">
|
||||||
|
The OIDC provided does not have a configured <Code>authorization_endpoint</Code>.
|
||||||
|
Ensure discovery URL or manual configuration is correct.
|
||||||
|
</Card.Text>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "MISSING_TOKEN_ENDPOINT":
|
||||||
|
messages.push({
|
||||||
|
key: error,
|
||||||
|
node: (
|
||||||
|
<Card.Text className="inline">
|
||||||
|
The OIDC provided does not have a configured <Code>token_endpoint</Code>. Ensure
|
||||||
|
discovery URL or manual configuration is correct.
|
||||||
|
</Card.Text>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "MISSING_USERINFO_ENDPOINT":
|
||||||
|
messages.push({
|
||||||
|
key: error,
|
||||||
|
node: (
|
||||||
|
<Card.Text className="inline">
|
||||||
|
The OIDC provided does not have a configured <Code>user_endpoint</Code>. Ensure
|
||||||
|
discovery URL or manual configuration is correct.
|
||||||
|
</Card.Text>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "MISSING_REQUIRED_CLAIMS":
|
||||||
|
messages.push({
|
||||||
|
key: error,
|
||||||
|
node: (
|
||||||
|
<Card.Text className="inline">
|
||||||
|
The OIDC provider does not support the <Code>sub</Code> claim, which is required for
|
||||||
|
authentication. Your OIDC provider may be misconfigured.
|
||||||
|
</Card.Text>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "UNKNOWN_ERROR":
|
||||||
|
messages.push({
|
||||||
|
key: error,
|
||||||
|
node: (
|
||||||
|
<Card.Text className="inline">
|
||||||
|
An unknown error occurred during OIDC configuration. Please check the Headplane logs
|
||||||
|
for more information.
|
||||||
|
</Card.Text>
|
||||||
|
),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { AlertCircle } from 'lucide-react';
|
||||||
|
import Card from '~/components/Card';
|
||||||
|
import Code from '~/components/Code';
|
||||||
|
|
||||||
|
export function OidcErrorNotice({ code }: { code: string }) {
|
||||||
|
return (
|
||||||
|
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<Card.Title className="text-red-500">Configuration Issue(s)</Card.Title>
|
||||||
|
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
||||||
|
</div>
|
||||||
|
{getErrorMessage(code)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(code: string) {
|
||||||
|
switch (code) {
|
||||||
|
case 'error_no_query':
|
||||||
|
return (
|
||||||
|
<Card.Text>
|
||||||
|
The SSO provider did not correctly redirect back to Headplane with the
|
||||||
|
required parameters. Please ensure your SSO provider is configured
|
||||||
|
correctly.
|
||||||
|
</Card.Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'error_no_session':
|
||||||
|
case 'error_invalid_session':
|
||||||
|
return (
|
||||||
|
<Card.Text>
|
||||||
|
Unable to complete SSO login due to missing or invalid session data.
|
||||||
|
Ensure that your Headplane cookie configuration is correct and that
|
||||||
|
your browser is accepting cookies.
|
||||||
|
</Card.Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'error_no_sub':
|
||||||
|
return (
|
||||||
|
<Card.Text>
|
||||||
|
The SSO provider did not return a valid user identifier. Please ensure
|
||||||
|
your SSO provider is correctly configured to provide the{' '}
|
||||||
|
<Code>sub</Code> claim.
|
||||||
|
</Card.Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'error_auth_failed':
|
||||||
|
return (
|
||||||
|
<Card.Text>
|
||||||
|
Authentication with the SSO provider failed. Please try again later.
|
||||||
|
Headplane logs may provide more information.
|
||||||
|
</Card.Text>
|
||||||
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Card.Text>
|
||||||
|
An unknown error occurred during OIDC authentication. Please try again
|
||||||
|
later.
|
||||||
|
</Card.Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+147
-124
@@ -1,132 +1,155 @@
|
|||||||
import { useEffect } from 'react';
|
import { AlertCircle } from "lucide-react";
|
||||||
import {
|
import { useEffect, useState } from "react";
|
||||||
ActionFunctionArgs,
|
import { Form, Link as RemixLink, redirect, useSearchParams } from "react-router";
|
||||||
data,
|
|
||||||
Form,
|
|
||||||
LoaderFunctionArgs,
|
|
||||||
Link as RemixLink,
|
|
||||||
redirect,
|
|
||||||
useActionData,
|
|
||||||
useLoaderData,
|
|
||||||
useSearchParams,
|
|
||||||
} from 'react-router';
|
|
||||||
import Button from '~/components/Button';
|
|
||||||
import Card from '~/components/Card';
|
|
||||||
import Code from '~/components/Code';
|
|
||||||
import Input from '~/components/Input';
|
|
||||||
import type { LoadContext } from '~/server';
|
|
||||||
import { useLiveData } from '~/utils/live-data';
|
|
||||||
import { loginAction } from './action';
|
|
||||||
import Logout from './logout';
|
|
||||||
|
|
||||||
export async function loader({
|
import Button from "~/components/Button";
|
||||||
request,
|
import Card from "~/components/Card";
|
||||||
context,
|
import Code from "~/components/Code";
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
import Input from "~/components/Input";
|
||||||
try {
|
import Link from "~/components/Link";
|
||||||
await context.sessions.auth(request);
|
import { useLiveData } from "~/utils/live-data";
|
||||||
return redirect('/machines');
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
const qp = new URL(request.url).searchParams;
|
import type { Route } from "./+types/page";
|
||||||
const state = qp.get('s') ?? undefined;
|
import { loginAction } from "./action";
|
||||||
|
import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error";
|
||||||
|
import Logout from "./logout";
|
||||||
|
import { OidcErrorNotice } from "./oidc-error";
|
||||||
|
|
||||||
// OIDC config cannot be undefined if an OIDC client is set
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
// Also check if we are in a logout state and skip redirect if we are
|
try {
|
||||||
const ssoOnly = context.config.oidc?.disable_api_key_login;
|
await context.auth.require(request);
|
||||||
if (state !== 'logout' && ssoOnly) {
|
return redirect("/machines");
|
||||||
// This shouldn't be possible, but still a safe sanity check
|
} catch {}
|
||||||
if (!context.oidc) {
|
|
||||||
throw data(
|
|
||||||
'`oidc.disable_api_key_login` was set without a valid OIDC configuration',
|
|
||||||
{
|
|
||||||
status: 400,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return redirect('/oidc/start');
|
const qp = new URL(request.url).searchParams;
|
||||||
}
|
const urlState = qp.get("s") ?? undefined;
|
||||||
|
|
||||||
return {
|
const oidcConnector = await context.oidc?.connector.get();
|
||||||
oidc: context.oidc,
|
|
||||||
state,
|
// MARK: This works because the OIDC connector will always return false
|
||||||
};
|
// for `isExclusive` if the OIDC config isn't usable.
|
||||||
|
if (oidcConnector?.isExclusive && urlState !== "logout") {
|
||||||
|
return redirect("/oidc/start");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isOidcConnectorEnabled = oidcConnector?.isValid;
|
||||||
|
const oidcErrorCodes = !isOidcConnectorEnabled ? (oidcConnector?.errors ?? []) : [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
isCookieSecureEnabled: context.config.server.cookie_secure,
|
||||||
|
isOidcConnectorEnabled,
|
||||||
|
oidcErrorCodes,
|
||||||
|
urlState,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action(request: ActionFunctionArgs<LoadContext>) {
|
export const action = loginAction;
|
||||||
return loginAction(request);
|
|
||||||
}
|
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
|
||||||
|
const { isCookieSecureEnabled, isOidcConnectorEnabled, oidcErrorCodes, urlState } = loaderData;
|
||||||
export default function Page() {
|
|
||||||
const { state, oidc } = useLoaderData<typeof loader>();
|
const [showCookieWarning, setShowCookieWarning] = useState(false);
|
||||||
const formData = useActionData<typeof action>();
|
const [params] = useSearchParams();
|
||||||
const [params] = useSearchParams();
|
const { pause } = useLiveData();
|
||||||
const { pause } = useLiveData();
|
|
||||||
|
useEffect(() => {
|
||||||
useEffect(() => {
|
// This page does NOT need stale while revalidate logic
|
||||||
// This page does NOT need stale while revalidate logic
|
pause();
|
||||||
pause();
|
|
||||||
});
|
if (isCookieSecureEnabled && window.location.protocol !== "https:") {
|
||||||
|
setShowCookieWarning(true);
|
||||||
useEffect(() => {
|
}
|
||||||
// State is a one time thing, we need to remove it after it has
|
});
|
||||||
// been consumed to prevent logic loops.
|
|
||||||
if (state !== null) {
|
useEffect(() => {
|
||||||
const searchParams = new URLSearchParams(params);
|
// State is a one time thing, we need to remove it after it has
|
||||||
searchParams.delete('s');
|
// been consumed to prevent logic loops.
|
||||||
|
if (urlState !== null) {
|
||||||
// Replacing because it's not a navigation, just a cleanup of the URL
|
const searchParams = new URLSearchParams(params);
|
||||||
// We can't use the useSearchParams method since it revalidates
|
searchParams.delete("s");
|
||||||
// which will trigger a full reload
|
|
||||||
const newUrl = searchParams.toString()
|
// Replacing because it's not a navigation, just a cleanup of the URL
|
||||||
? `{${window.location.pathname}?${searchParams.toString()}`
|
// We can't use the useSearchParams method since it revalidates
|
||||||
: window.location.pathname;
|
// which will trigger a full reload
|
||||||
|
const newUrl = searchParams.toString()
|
||||||
window.history.replaceState(null, '', newUrl);
|
? `{${window.location.pathname}?${searchParams.toString()}`
|
||||||
}
|
: window.location.pathname;
|
||||||
}, [state, params]);
|
|
||||||
|
window.history.replaceState(null, "", newUrl);
|
||||||
if (state === 'logout') {
|
}
|
||||||
return <Logout />;
|
}, [urlState, params]);
|
||||||
}
|
|
||||||
|
if (urlState === "logout") {
|
||||||
return (
|
return <Logout />;
|
||||||
<div className="flex w-screen h-screen items-center justify-center">
|
}
|
||||||
<Card className="max-w-md m-4 sm:m-0">
|
|
||||||
<Card.Title>Welcome to Headplane</Card.Title>
|
return (
|
||||||
<Form method="POST">
|
<div className="flex h-screen w-screen items-center justify-center">
|
||||||
<Card.Text>
|
<div>
|
||||||
Enter an API key to authenticate with Headplane. You can generate
|
{urlState?.startsWith("error_") ? (
|
||||||
one by running <Code>headscale apikeys create</Code> in your
|
<OidcErrorNotice code={urlState} />
|
||||||
terminal.
|
) : oidcErrorCodes.includes("DISCOVERY_FAILED") ? (
|
||||||
</Card.Text>
|
<OidcDiscoveryFailedNotice />
|
||||||
<Input
|
) : oidcErrorCodes.length > 0 ? (
|
||||||
className="mt-8 mb-2"
|
<OidcConfigErrorNotice errors={oidcErrorCodes} />
|
||||||
isRequired
|
) : showCookieWarning ? (
|
||||||
label="API Key"
|
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
|
||||||
labelHidden
|
<div className="flex items-center justify-between gap-4">
|
||||||
name="api_key"
|
<Card.Title className="text-red-500">Configuration Issue</Card.Title>
|
||||||
placeholder="API Key"
|
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
|
||||||
type="password"
|
</div>
|
||||||
/>
|
{showCookieWarning ? (
|
||||||
{formData?.success === false ? (
|
<Card.Text className="text-sm">
|
||||||
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
|
Headplane is configured to use secure cookies, but this site is being served over an
|
||||||
{formData.message}
|
insecure connection and login will not work correctly.{" "}
|
||||||
</Card.Text>
|
<Link
|
||||||
) : undefined}
|
name="Headplane Common Issues"
|
||||||
<Button className="w-full" type="submit" variant="heavy">
|
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
|
||||||
Sign In
|
>
|
||||||
</Button>
|
Learn more.
|
||||||
</Form>
|
</Link>
|
||||||
{oidc ? (
|
</Card.Text>
|
||||||
<RemixLink to="/oidc/start">
|
) : undefined}
|
||||||
<Button className="w-full mt-2" variant="light">
|
</Card>
|
||||||
Single Sign-On
|
) : undefined}
|
||||||
</Button>
|
<Card className="m-4 max-w-md sm:m-0">
|
||||||
</RemixLink>
|
<Card.Title>Welcome to Headplane</Card.Title>
|
||||||
) : undefined}
|
<Form method="POST">
|
||||||
</Card>
|
<Card.Text>
|
||||||
</div>
|
Enter an API key to authenticate with Headplane. You can generate one by running{" "}
|
||||||
);
|
<Code>headscale apikeys create</Code> in your terminal.
|
||||||
|
</Card.Text>
|
||||||
|
<Input
|
||||||
|
className="mt-8 mb-2"
|
||||||
|
isRequired
|
||||||
|
label="API Key"
|
||||||
|
labelHidden
|
||||||
|
name="api_key"
|
||||||
|
placeholder="API Key"
|
||||||
|
type="password"
|
||||||
|
/>
|
||||||
|
{actionData?.success === false ? (
|
||||||
|
<Card.Text className="mb-2 text-sm text-red-600 dark:text-red-300">
|
||||||
|
{actionData.message}
|
||||||
|
</Card.Text>
|
||||||
|
) : undefined}
|
||||||
|
<Button className="w-full" type="submit" variant="heavy">
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</Form>
|
||||||
|
{isOidcConnectorEnabled ? (
|
||||||
|
<RemixLink to="/oidc/start">
|
||||||
|
<Button
|
||||||
|
className="mt-2 w-full"
|
||||||
|
isDisabled={oidcErrorCodes.length > 0}
|
||||||
|
variant="light"
|
||||||
|
>
|
||||||
|
Single Sign-On
|
||||||
|
</Button>
|
||||||
|
</RemixLink>
|
||||||
|
) : undefined}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-22
@@ -1,29 +1,25 @@
|
|||||||
import { type ActionFunctionArgs, redirect } from 'react-router';
|
import { type ActionFunctionArgs, redirect } from "react-router";
|
||||||
import type { LoadContext } from '~/server';
|
|
||||||
|
import type { LoadContext } from "~/server";
|
||||||
|
|
||||||
export async function loader() {
|
export async function loader() {
|
||||||
return redirect('/machines');
|
return redirect("/machines");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({
|
export async function action({ request, context }: ActionFunctionArgs<LoadContext>) {
|
||||||
request,
|
try {
|
||||||
context,
|
await context.auth.require(request);
|
||||||
}: ActionFunctionArgs<LoadContext>) {
|
} catch {
|
||||||
try {
|
redirect("/login");
|
||||||
await context.sessions.auth(request);
|
}
|
||||||
} catch {
|
|
||||||
redirect('/login');
|
|
||||||
}
|
|
||||||
|
|
||||||
// When API key is disabled, we need to explicitly redirect
|
// When API key is disabled, we need to explicitly redirect
|
||||||
// with a logout state to prevent auto login again.
|
// with a logout state to prevent auto login again.
|
||||||
const url = context.config.oidc?.disable_api_key_login
|
const url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
|
||||||
? '/login?s=logout'
|
|
||||||
: '/login';
|
|
||||||
|
|
||||||
return redirect(url, {
|
return redirect(url, {
|
||||||
headers: {
|
headers: {
|
||||||
'Set-Cookie': await context.sessions.destroySession(),
|
"Set-Cookie": await context.auth.destroySession(request),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+146
-129
@@ -1,131 +1,148 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from "node:crypto";
|
||||||
import { count, eq } from 'drizzle-orm';
|
|
||||||
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
|
|
||||||
import { ulid } from 'ulidx';
|
|
||||||
import type { LoadContext } from '~/server';
|
|
||||||
import { HeadplaneConfig } from '~/server/config/schema';
|
|
||||||
import { users } from '~/server/db/schema';
|
|
||||||
import { Roles } from '~/server/web/roles';
|
|
||||||
import { FlowUser, finishAuthFlow, formatError } from '~/utils/oidc';
|
|
||||||
import { send } from '~/utils/res';
|
|
||||||
|
|
||||||
interface OidcFlowSession {
|
import * as oidc from "openid-client";
|
||||||
state: string;
|
import { data, redirect } from "react-router";
|
||||||
nonce: string;
|
|
||||||
code_verifier: string;
|
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
||||||
redirect_uri: string;
|
import log from "~/utils/log";
|
||||||
}
|
import { createOidcStateCookie } from "~/utils/oidc-state";
|
||||||
|
|
||||||
export async function loader({
|
import type { Route } from "./+types/oidc-callback";
|
||||||
request,
|
|
||||||
context,
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
const oidcConnector = await context.oidc?.connector.get();
|
||||||
if (!context.oidc) {
|
if (!oidcConnector?.isValid) {
|
||||||
throw new Error('OIDC is not enabled');
|
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we have 0 query parameters
|
const url = new URL(request.url);
|
||||||
const url = new URL(request.url);
|
if (url.searchParams.toString().length === 0) {
|
||||||
if (url.searchParams.toString().length === 0) {
|
return redirect("/login?s=error_no_query");
|
||||||
return redirect('/login');
|
}
|
||||||
}
|
|
||||||
|
const cookie = createOidcStateCookie(context.config);
|
||||||
const cookie = createCookie('__oidc_auth_flow', {
|
const oidcCookieState = await cookie.parse(request.headers.get("Cookie"));
|
||||||
httpOnly: true,
|
|
||||||
maxAge: 300, // 5 minutes
|
if (oidcCookieState == null) {
|
||||||
});
|
log.warn("auth", "Called OIDC callback without session cookie");
|
||||||
|
return redirect("/login?s=error_no_session");
|
||||||
const data: OidcFlowSession | null = await cookie.parse(
|
}
|
||||||
request.headers.get('Cookie'),
|
|
||||||
);
|
const { state, nonce, redirect_uri, verifier } = oidcCookieState;
|
||||||
|
if (!state || !nonce || !redirect_uri || !verifier) {
|
||||||
if (data === null) {
|
log.warn("auth", "OIDC session cookie is missing required fields");
|
||||||
console.warn('OIDC flow session not found');
|
return redirect("/login?s=error_invalid_session");
|
||||||
return redirect('/login');
|
}
|
||||||
}
|
|
||||||
|
try {
|
||||||
const { code_verifier, state, nonce, redirect_uri } = data;
|
const callbackUrl = new URL(redirect_uri);
|
||||||
if (!code_verifier || !state || !nonce || !redirect_uri) {
|
const currentUrl = new URL(request.url);
|
||||||
return send({ error: 'Missing OIDC state' }, { status: 400 });
|
callbackUrl.search = currentUrl.search;
|
||||||
}
|
|
||||||
|
const tokens = await oidc.authorizationCodeGrant(oidcConnector.client, callbackUrl, {
|
||||||
// Reconstruct the redirect URI using the query parameters
|
expectedState: state,
|
||||||
// and the one we saved in the session
|
expectedNonce: nonce,
|
||||||
const flowRedirectUri = new URL(redirect_uri);
|
...(oidcConnector.usePKCE ? { pkceCodeVerifier: verifier } : {}),
|
||||||
flowRedirectUri.search = url.search;
|
});
|
||||||
|
|
||||||
const flowOptions = {
|
const claims = tokens.claims();
|
||||||
redirect_uri: flowRedirectUri.toString(),
|
if (claims?.sub == null) {
|
||||||
code_verifier,
|
log.warn("auth", "No subject found in OIDC claims");
|
||||||
state,
|
return redirect("/login?s=error_no_sub");
|
||||||
nonce: nonce === '<none>' ? undefined : nonce,
|
}
|
||||||
};
|
|
||||||
|
const userInfo = await oidc.fetchUserInfo(
|
||||||
try {
|
oidcConnector.client,
|
||||||
let user = await finishAuthFlow(context.oidc, flowOptions);
|
tokens.access_token,
|
||||||
user = {
|
claims.sub,
|
||||||
...user,
|
);
|
||||||
picture: setOidcPictureForSource(
|
|
||||||
user,
|
// We have defaults that closely follow what Headscale uses, maybe we
|
||||||
context.config.oidc?.profile_picture_source ?? 'oidc',
|
// can make it configurable in the future, but for now we only need the
|
||||||
),
|
// `sub` claim.
|
||||||
};
|
const username = userInfo.preferred_username ?? userInfo.email?.split("@")[0] ?? "user";
|
||||||
|
const name =
|
||||||
const [{ count: userCount }] = await context.db
|
userInfo.name ??
|
||||||
.select({ count: count() })
|
(userInfo.given_name && userInfo.family_name
|
||||||
.from(users)
|
? `${userInfo.given_name} ${userInfo.family_name}`
|
||||||
.where(eq(users.caps, Roles.owner));
|
: (userInfo.preferred_username ?? "SSO User"));
|
||||||
|
|
||||||
await context.db
|
const picture =
|
||||||
.insert(users)
|
context.config.oidc?.profile_picture_source === "gravatar"
|
||||||
.values({
|
? (() => {
|
||||||
id: ulid(),
|
if (!userInfo.email) {
|
||||||
sub: user.subject,
|
return undefined;
|
||||||
caps: userCount === 0 ? Roles.owner : Roles.member,
|
}
|
||||||
})
|
|
||||||
.onConflictDoNothing();
|
const emailHash = userInfo.email.trim().toLowerCase();
|
||||||
|
const hash = createHash("sha256").update(emailHash).digest("hex");
|
||||||
return redirect('/machines', {
|
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
|
||||||
headers: {
|
})()
|
||||||
'Set-Cookie': await context.sessions.createSession({
|
: userInfo.picture;
|
||||||
// TODO: This is breaking, to stop the "over-generation" of API
|
|
||||||
// keys because they are currently non-deletable in the headscale
|
const userId = await context.auth.findOrCreateUser(claims.sub);
|
||||||
// database. Look at this in the future once we have a solution
|
|
||||||
// or we have permissioned API keys.
|
try {
|
||||||
api_key: context.config.oidc?.headscale_api_key!,
|
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
|
||||||
user,
|
const hsUsers = await hsApi.getUsers();
|
||||||
}),
|
const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email);
|
||||||
},
|
if (hsUser) {
|
||||||
});
|
await context.auth.linkHeadscaleUser(userId, hsUser.id);
|
||||||
} catch (error) {
|
}
|
||||||
return new Response(JSON.stringify(formatError(error)), {
|
} catch (error) {
|
||||||
status: 500,
|
log.warn("auth", "Failed to link Headscale user: %s", String(error));
|
||||||
headers: {
|
}
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
return redirect("/", {
|
||||||
});
|
headers: {
|
||||||
}
|
"Set-Cookie": await context.auth.createOidcSession(userId, {
|
||||||
}
|
name,
|
||||||
|
email: userInfo.email,
|
||||||
type PictureSource = NonNullable<
|
username,
|
||||||
HeadplaneConfig['oidc']
|
picture,
|
||||||
>['profile_picture_source'];
|
}),
|
||||||
|
},
|
||||||
function setOidcPictureForSource(user: FlowUser, source: PictureSource) {
|
});
|
||||||
// Already set by default in the callback, so we can just return it
|
} catch (error) {
|
||||||
if (source === 'oidc') {
|
if (error instanceof oidc.ResponseBodyError) {
|
||||||
return user.picture;
|
log.error("auth", "Got an OIDC response error body: %s", JSON.stringify(error.cause));
|
||||||
}
|
|
||||||
|
// Check for PKCE-related errors
|
||||||
if (source === 'gravatar') {
|
if (
|
||||||
if (!user.email) {
|
error.error.toLowerCase().includes("code_verifier") ||
|
||||||
return undefined;
|
error.error.toLowerCase().includes("code verifier") ||
|
||||||
}
|
error.error.toLowerCase().includes("pkce")
|
||||||
|
) {
|
||||||
const emailHash = user.email.trim().toLowerCase();
|
log.error(
|
||||||
const hash = createHash('sha256').update(emailHash).digest('hex');
|
"auth",
|
||||||
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
|
"PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s",
|
||||||
}
|
oidcConnector.usePKCE,
|
||||||
|
);
|
||||||
return undefined;
|
|
||||||
|
if (!oidcConnector.usePKCE) {
|
||||||
|
log.error(
|
||||||
|
"auth",
|
||||||
|
"Consider setting oidc.use_pkce=true in your configuration if your provider requires PKCE",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (error instanceof oidc.AuthorizationResponseError) {
|
||||||
|
log.error("auth", "Got an OIDC authorization response error: %s", error.error);
|
||||||
|
} else if (error instanceof oidc.WWWAuthenticateChallengeError) {
|
||||||
|
log.error("auth", "Got an OIDC WWW-Authenticate challenge error");
|
||||||
|
} else if (error instanceof oidc.ClientError) {
|
||||||
|
log.error(
|
||||||
|
"auth",
|
||||||
|
"Got an OIDC authorization client error: %s",
|
||||||
|
error.cause instanceof Error ? error.cause.message : String(error.cause),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
log.error(
|
||||||
|
"auth",
|
||||||
|
"Got an OIDC error: %s",
|
||||||
|
error instanceof Error && error.cause ? JSON.stringify(error.cause) : String(error),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return redirect("/login?s=error_auth_failed");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +1,81 @@
|
|||||||
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
|
import * as oidc from "openid-client";
|
||||||
import type { LoadContext } from '~/server';
|
import { data, redirect } from "react-router";
|
||||||
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
|
|
||||||
|
|
||||||
export async function loader({
|
import { HeadplaneConfig } from "~/server/config/config-schema";
|
||||||
request,
|
import { createOidcStateCookie } from "~/utils/oidc-state";
|
||||||
context,
|
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
|
||||||
try {
|
|
||||||
await context.sessions.auth(request);
|
|
||||||
return redirect('/machines');
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (!context.oidc || !context.config.oidc) {
|
import type { Route } from "./+types/oidc-start";
|
||||||
throw new Error('OIDC is not enabled');
|
|
||||||
}
|
|
||||||
|
|
||||||
const cookie = createCookie('__oidc_auth_flow', {
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
httpOnly: true,
|
try {
|
||||||
maxAge: 300, // 5 minutes
|
await context.auth.require(request);
|
||||||
});
|
return redirect("/");
|
||||||
|
} catch {}
|
||||||
|
|
||||||
const redirectUri =
|
const oidcConnector = await context.oidc?.connector.get();
|
||||||
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
|
if (!oidcConnector?.isValid) {
|
||||||
const data = await beginAuthFlow(
|
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
||||||
context.oidc,
|
}
|
||||||
redirectUri,
|
|
||||||
context.config.oidc.scope,
|
|
||||||
context.config.oidc.extra_params,
|
|
||||||
);
|
|
||||||
|
|
||||||
return redirect(data.url, {
|
const cookie = createOidcStateCookie(context.config);
|
||||||
status: 302,
|
const redirect_uri = getRedirectUri(context.config, request);
|
||||||
headers: {
|
|
||||||
'Set-Cookie': await cookie.serialize({
|
const nonce = oidc.randomNonce();
|
||||||
state: data.state,
|
const verifier = oidc.randomPKCECodeVerifier();
|
||||||
nonce: data.nonce,
|
const state = oidc.randomState();
|
||||||
code_verifier: data.codeVerifier,
|
|
||||||
redirect_uri: redirectUri,
|
const url = oidc.buildAuthorizationUrl(oidcConnector.client, {
|
||||||
}),
|
...oidcConnector.extraParams,
|
||||||
},
|
scope: oidcConnector.scope,
|
||||||
});
|
redirect_uri,
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
...(oidcConnector.usePKCE
|
||||||
|
? {
|
||||||
|
code_challenge_method: "S256",
|
||||||
|
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return redirect(url.href, {
|
||||||
|
status: 302,
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie": await cookie.serialize({
|
||||||
|
state,
|
||||||
|
nonce,
|
||||||
|
verifier,
|
||||||
|
redirect_uri,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRedirectUri(config: HeadplaneConfig, req: Request): string {
|
||||||
|
if (config.server.base_url != null) {
|
||||||
|
const url = new URL(`${__PREFIX__}/oidc/callback`, config.server.base_url);
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.oidc?.redirect_uri != null) {
|
||||||
|
const url = new URL(`${__PREFIX__}/oidc/callback`, config.oidc.redirect_uri);
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
|
||||||
|
let host = req.headers.get("Host");
|
||||||
|
if (!host) {
|
||||||
|
host = req.headers.get("X-Forwarded-Host");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!host) {
|
||||||
|
throw data("Cannot determine redirect URI: no Host or X-Forwarded-Host header", {
|
||||||
|
status: 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const proto = req.headers.get("X-Forwarded-Proto");
|
||||||
|
url.protocol = proto ?? "http:";
|
||||||
|
url.host = host;
|
||||||
|
return url.href;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
|
import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
|
||||||
import {
|
import {
|
||||||
restrictToParentElement,
|
restrictToParentElement,
|
||||||
restrictToVerticalAxis,
|
restrictToVerticalAxis,
|
||||||
} from '@dnd-kit/modifiers';
|
} from '@dnd-kit/modifiers';
|
||||||
import {
|
import {
|
||||||
SortableContext,
|
|
||||||
arrayMove,
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
useSortable,
|
useSortable,
|
||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { GripVertical, Lock } from 'lucide-react';
|
import { GripVertical, Lock } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { type FetcherWithComponents, Form, useFetcher } from 'react-router';
|
import { Form } from 'react-router';
|
||||||
import Button from '~/components/Button';
|
import Button from '~/components/Button';
|
||||||
import Input from '~/components/Input';
|
import Input from '~/components/Input';
|
||||||
import TableList from '~/components/TableList';
|
import TableList from '~/components/TableList';
|
||||||
@@ -37,18 +37,15 @@ export default function ManageDomains({
|
|||||||
}, [searchDomains]);
|
}, [searchDomains]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-full sm:w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
|
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Set custom DNS search domains for your Tailnet. When using Magic DNS,
|
Set custom DNS search domains for your Tailnet. When using Magic DNS,
|
||||||
your tailnet domain is used as the first search domain.
|
your tailnet domain is used as the first search domain.
|
||||||
</p>
|
</p>
|
||||||
<DndContext
|
<DndContext
|
||||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
|
||||||
collisionDetection={closestCorners}
|
collisionDetection={closestCorners}
|
||||||
onDragStart={(event) => {
|
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||||
setActiveId(event.active.id);
|
|
||||||
}}
|
|
||||||
onDragEnd={(event) => {
|
onDragEnd={(event) => {
|
||||||
setActiveId(null);
|
setActiveId(null);
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
@@ -70,6 +67,9 @@ export default function ManageDomains({
|
|||||||
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
|
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
onDragStart={(event) => {
|
||||||
|
setActiveId(event.active.id);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<TableList>
|
<TableList>
|
||||||
{magic ? (
|
{magic ? (
|
||||||
@@ -91,19 +91,19 @@ export default function ManageDomains({
|
|||||||
>
|
>
|
||||||
{localDomains.map((sd, index) => (
|
{localDomains.map((sd, index) => (
|
||||||
<Domain
|
<Domain
|
||||||
key={sd}
|
|
||||||
domain={sd}
|
domain={sd}
|
||||||
id={index + 1}
|
id={index + 1}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
|
key={sd}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<DragOverlay adjustScale>
|
<DragOverlay adjustScale>
|
||||||
{activeId ? (
|
{activeId ? (
|
||||||
<Domain
|
<Domain
|
||||||
isDragging
|
|
||||||
domain={localDomains[(activeId as number) - 1]}
|
domain={localDomains[(activeId as number) - 1]}
|
||||||
id={(activeId as number) - 1}
|
id={(activeId as number) - 1}
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
|
isDragging
|
||||||
/>
|
/>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
@@ -111,28 +111,28 @@ export default function ManageDomains({
|
|||||||
{isDisabled ? undefined : (
|
{isDisabled ? undefined : (
|
||||||
<TableList.Item key="add-sd">
|
<TableList.Item key="add-sd">
|
||||||
<Form
|
<Form
|
||||||
method="POST"
|
|
||||||
className="flex items-center justify-between w-full"
|
className="flex items-center justify-between w-full"
|
||||||
|
method="POST"
|
||||||
>
|
>
|
||||||
<input type="hidden" name="action_id" value="add_domain" />
|
<input name="action_id" type="hidden" value="add_domain" />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-none font-mono p-0 text-sm',
|
'border-none font-mono p-0 text-sm',
|
||||||
'rounded-none focus:ring-0 w-full ml-1',
|
'rounded-none focus:ring-0 w-full ml-1',
|
||||||
)}
|
)}
|
||||||
placeholder="Search Domain"
|
|
||||||
label="Search Domain"
|
|
||||||
name="domain"
|
|
||||||
labelHidden
|
|
||||||
isRequired
|
isRequired
|
||||||
|
label="Search Domain"
|
||||||
|
labelHidden
|
||||||
|
name="domain"
|
||||||
|
placeholder="Search Domain"
|
||||||
|
type="text"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 py-1 rounded-md',
|
'px-2 py-1 rounded-md',
|
||||||
'text-blue-500 dark:text-blue-400',
|
'text-blue-500 dark:text-blue-400',
|
||||||
)}
|
)}
|
||||||
|
type="submit"
|
||||||
>
|
>
|
||||||
Add
|
Add
|
||||||
</Button>
|
</Button>
|
||||||
@@ -164,11 +164,11 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<TableList.Item
|
<TableList.Item
|
||||||
ref={setNodeRef}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
isSortableDragging ? 'opacity-50' : '',
|
isSortableDragging ? 'opacity-50' : '',
|
||||||
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
|
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
|
||||||
)}
|
)}
|
||||||
|
ref={setNodeRef}
|
||||||
style={{
|
style={{
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
@@ -186,15 +186,15 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
|
|||||||
</p>
|
</p>
|
||||||
{isDragging ? undefined : (
|
{isDragging ? undefined : (
|
||||||
<Form method="POST">
|
<Form method="POST">
|
||||||
<input type="hidden" name="action_id" value="remove_domain" />
|
<input name="action_id" type="hidden" value="remove_domain" />
|
||||||
<input type="hidden" name="domain" value={domain} />
|
<input name="domain" type="hidden" value={domain} />
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 py-1 rounded-md',
|
'px-2 py-1 rounded-md',
|
||||||
'text-red-500 dark:text-red-400',
|
'text-red-500 dark:text-red-400',
|
||||||
)}
|
)}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
type="submit"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -20,14 +20,14 @@ export default function ManageNS({
|
|||||||
overrideLocalDns,
|
overrideLocalDns,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-full sm:w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
|
||||||
<p>
|
<p>
|
||||||
Set the nameservers used by devices on the Tailnet to resolve DNS
|
Set the nameservers used by devices on the Tailnet to resolve DNS
|
||||||
queries.{' '}
|
queries.{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://tailscale.com/kb/1054/dns"
|
|
||||||
name="Tailscale DNS Documentation"
|
name="Tailscale DNS Documentation"
|
||||||
|
to="https://tailscale.com/kb/1054/dns"
|
||||||
>
|
>
|
||||||
Learn more
|
Learn more
|
||||||
</Link>
|
</Link>
|
||||||
@@ -35,12 +35,12 @@ export default function ManageNS({
|
|||||||
<div className="mt-4">
|
<div className="mt-4">
|
||||||
{Object.keys(nameservers).map((key) => (
|
{Object.keys(nameservers).map((key) => (
|
||||||
<NameserverList
|
<NameserverList
|
||||||
key={key}
|
|
||||||
isGlobal={key === 'global'}
|
|
||||||
isDisabled={isDisabled}
|
isDisabled={isDisabled}
|
||||||
|
isGlobal={key === 'global'}
|
||||||
|
key={key}
|
||||||
|
name={key}
|
||||||
nameservers={nameservers}
|
nameservers={nameservers}
|
||||||
overrideLocalDns={overrideLocalDns}
|
overrideLocalDns={overrideLocalDns}
|
||||||
name={key}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
@@ -66,16 +66,17 @@ function NameserverList({
|
|||||||
name,
|
name,
|
||||||
}: ListProps) {
|
}: ListProps) {
|
||||||
const list = isGlobal ? nameservers.global : nameservers[name];
|
const list = isGlobal ? nameservers.global : nameservers[name];
|
||||||
|
const submit = useSubmit();
|
||||||
|
|
||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = useSubmit();
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
{isGlobal ? (
|
{isGlobal ? (
|
||||||
<div className="flex items-center justify-between w-full">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-2">
|
||||||
<h2 className="text-md font-medium opacity-80">
|
<h2 className="text-md font-medium opacity-80">
|
||||||
Global Nameservers
|
Global Nameservers
|
||||||
</h2>
|
</h2>
|
||||||
@@ -87,8 +88,8 @@ function NameserverList({
|
|||||||
names outside the tailnet. When disabled (default), devices
|
names outside the tailnet. When disabled (default), devices
|
||||||
will prefer their local DNS configuration.
|
will prefer their local DNS configuration.
|
||||||
<Link
|
<Link
|
||||||
to="https://tailscale.com/kb/1054/dns#global-nameservers"
|
|
||||||
name="Tailscale Global Nameservers Documentation"
|
name="Tailscale Global Nameservers Documentation"
|
||||||
|
to="https://tailscale.com/kb/1054/dns#global-nameservers"
|
||||||
>
|
>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
@@ -96,11 +97,10 @@ function NameserverList({
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
<p>Override DNS servers</p>
|
<p>Override DNS servers</p>
|
||||||
<Switch
|
<Switch
|
||||||
label="Override local DNS settings"
|
className="h-[15px] w-[23px] p-0.5"
|
||||||
className="h-[15px] w-[23px] p-[2px]"
|
|
||||||
switchClassName="h-[9px] w-[9px]"
|
|
||||||
name="override_dns"
|
|
||||||
defaultSelected={overrideLocalDns}
|
defaultSelected={overrideLocalDns}
|
||||||
|
label="Override local DNS settings"
|
||||||
|
name="override_dns"
|
||||||
onChange={(v) => {
|
onChange={(v) => {
|
||||||
submit(
|
submit(
|
||||||
{
|
{
|
||||||
@@ -112,6 +112,7 @@ function NameserverList({
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
switchClassName="h-[9px] w-[9px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,20 +126,20 @@ function NameserverList({
|
|||||||
<TableList.Item key={ns}>
|
<TableList.Item key={ns}>
|
||||||
<p className="font-mono text-sm">{ns}</p>
|
<p className="font-mono text-sm">{ns}</p>
|
||||||
<Form method="POST">
|
<Form method="POST">
|
||||||
<input type="hidden" name="action_id" value="remove_ns" />
|
<input name="action_id" type="hidden" value="remove_ns" />
|
||||||
<input type="hidden" name="ns" value={ns} />
|
<input name="ns" type="hidden" value={ns} />
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
|
||||||
name="split_name"
|
name="split_name"
|
||||||
|
type="hidden"
|
||||||
value={isGlobal ? 'global' : name}
|
value={isGlobal ? 'global' : name}
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
isDisabled={isDisabled}
|
|
||||||
type="submit"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 py-1 rounded-md',
|
'px-2 py-1 rounded-md',
|
||||||
'text-red-500 dark:text-red-400',
|
'text-red-500 dark:text-red-400',
|
||||||
)}
|
)}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
type="submit"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -13,14 +13,14 @@ interface Props {
|
|||||||
|
|
||||||
export default function ManageRecords({ records, isDisabled }: Props) {
|
export default function ManageRecords({ records, isDisabled }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex flex-col w-full sm:w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
|
||||||
<p>
|
<p>
|
||||||
Headscale supports adding custom DNS records to your Tailnet. As of now,
|
Headscale supports adding custom DNS records to your Tailnet. As of now,
|
||||||
only <Code>A</Code> and <Code>AAAA</Code> records are supported.{' '}
|
only <Code>A</Code> and <Code>AAAA</Code> records are supported.{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://headscale.net/stable/ref/dns"
|
|
||||||
name="Headscale DNS Records documentation"
|
name="Headscale DNS Records documentation"
|
||||||
|
to="https://headscale.net/stable/ref/dns"
|
||||||
>
|
>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
@@ -32,7 +32,7 @@ export default function ManageRecords({ records, isDisabled }: Props) {
|
|||||||
<p className="opacity-50 mx-auto">No DNS records found</p>
|
<p className="opacity-50 mx-auto">No DNS records found</p>
|
||||||
</TableList.Item>
|
</TableList.Item>
|
||||||
) : (
|
) : (
|
||||||
records.map((record, index) => (
|
records.map((record) => (
|
||||||
<TableList.Item key={`${record.name}-${record.value}`}>
|
<TableList.Item key={`${record.name}-${record.value}`}>
|
||||||
<div className="flex gap-2 items-center w-full">
|
<div className="flex gap-2 items-center w-full">
|
||||||
<p
|
<p
|
||||||
@@ -43,22 +43,24 @@ export default function ManageRecords({ records, isDisabled }: Props) {
|
|||||||
>
|
>
|
||||||
{record.type}
|
{record.type}
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2 w-full">
|
<div className="flex flex-col sm:flex-row sm:gap-2 flex-1 min-w-0">
|
||||||
<p className="font-mono text-sm">{record.name}</p>
|
<p className="font-mono text-sm truncate">{record.name}</p>
|
||||||
<p className="font-mono text-sm">{record.value}</p>
|
<p className="font-mono text-sm truncate opacity-70 sm:opacity-100">
|
||||||
|
{record.value}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Form method="POST">
|
<Form method="POST">
|
||||||
<input type="hidden" name="action_id" value="remove_record" />
|
<input name="action_id" type="hidden" value="remove_record" />
|
||||||
<input type="hidden" name="record_name" value={record.name} />
|
<input name="record_name" type="hidden" value={record.name} />
|
||||||
<input type="hidden" name="record_type" value={record.type} />
|
<input name="record_type" type="hidden" value={record.type} />
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 py-1 rounded-md',
|
'px-2 py-1 rounded-md',
|
||||||
'text-red-500 dark:text-red-400',
|
'text-red-500 dark:text-red-400',
|
||||||
)}
|
)}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
type="submit"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -9,21 +9,21 @@ interface Props {
|
|||||||
|
|
||||||
export default function RenameTailnet({ name, isDisabled }: Props) {
|
export default function RenameTailnet({ name, isDisabled }: Props) {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col w-2/3 gap-y-4">
|
<div className="flex flex-col w-full sm:w-2/3 gap-y-4">
|
||||||
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
|
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
|
||||||
<p>
|
<p>
|
||||||
This is the base domain name of your Tailnet. Devices are accessible at{' '}
|
This is the base domain name of your Tailnet. Devices are accessible at{' '}
|
||||||
<Code>[device].{name}</Code> when Magic DNS is enabled.
|
<Code>[device].{name}</Code> when Magic DNS is enabled.
|
||||||
</p>
|
</p>
|
||||||
<Input
|
<Input
|
||||||
isReadOnly
|
|
||||||
labelHidden
|
|
||||||
className="w-3/5 font-medium text-sm"
|
className="w-3/5 font-medium text-sm"
|
||||||
|
isReadOnly
|
||||||
label="Tailnet name"
|
label="Tailnet name"
|
||||||
value={name}
|
labelHidden
|
||||||
onFocus={(event) => {
|
onFocus={(event) => {
|
||||||
event.target.select();
|
event.target.select();
|
||||||
}}
|
}}
|
||||||
|
value={name}
|
||||||
/>
|
/>
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
|
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
|
||||||
@@ -33,13 +33,13 @@ export default function RenameTailnet({ name, isDisabled }: Props) {
|
|||||||
Keep in mind that changing this can lead to all sorts of unexpected
|
Keep in mind that changing this can lead to all sorts of unexpected
|
||||||
behavior and may break existing devices in your tailnet.
|
behavior and may break existing devices in your tailnet.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<input type="hidden" name="action_id" value="rename_tailnet" />
|
<input name="action_id" type="hidden" value="rename_tailnet" />
|
||||||
<Input
|
<Input
|
||||||
|
defaultValue={name}
|
||||||
isRequired
|
isRequired
|
||||||
label="Tailnet name"
|
label="Tailnet name"
|
||||||
placeholder="ts.net"
|
|
||||||
defaultValue={name}
|
|
||||||
name="new_name"
|
name="new_name"
|
||||||
|
placeholder="ts.net"
|
||||||
/>
|
/>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
+225
-245
@@ -1,251 +1,231 @@
|
|||||||
import { ActionFunctionArgs, data } from 'react-router';
|
import { data } from "react-router";
|
||||||
import { LoadContext } from '~/server';
|
|
||||||
import { Capabilities } from '~/server/web/roles';
|
|
||||||
|
|
||||||
export async function dnsAction({
|
import { Capabilities } from "~/server/web/roles";
|
||||||
request,
|
|
||||||
context,
|
|
||||||
}: ActionFunctionArgs<LoadContext>) {
|
|
||||||
const check = await context.sessions.check(
|
|
||||||
request,
|
|
||||||
Capabilities.write_network,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!check) {
|
import type { Route } from "./+types/overview";
|
||||||
return data({ success: false }, 403);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.hs.writable()) {
|
export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||||
return data({ success: false }, 403);
|
const principal = await context.auth.require(request);
|
||||||
}
|
const check = context.auth.can(principal, Capabilities.write_network);
|
||||||
|
|
||||||
const formData = await request.formData();
|
if (!check) {
|
||||||
const action = formData.get('action_id')?.toString();
|
return data({ success: false }, 403);
|
||||||
if (!action) {
|
}
|
||||||
return data({ success: false }, 400);
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (action) {
|
if (!context.hs.writable()) {
|
||||||
case 'rename_tailnet':
|
return data({ success: false }, 403);
|
||||||
return renameTailnet(formData, context);
|
}
|
||||||
case 'toggle_magic':
|
|
||||||
return toggleMagic(formData, context);
|
// We only need it for health checks which don't require auth
|
||||||
case 'remove_ns':
|
const api = context.hsApi.getRuntimeClient("fake-api-key");
|
||||||
return removeNs(formData, context);
|
|
||||||
case 'add_ns':
|
const formData = await request.formData();
|
||||||
return addNs(formData, context);
|
const action = formData.get("action_id")?.toString();
|
||||||
case 'remove_domain':
|
if (!action) {
|
||||||
return removeDomain(formData, context);
|
return data({ success: false }, 400);
|
||||||
case 'add_domain':
|
}
|
||||||
return addDomain(formData, context);
|
|
||||||
case 'remove_record':
|
switch (action) {
|
||||||
return removeRecord(formData, context);
|
case "rename_tailnet": {
|
||||||
case 'add_record':
|
const newName = formData.get("new_name")?.toString();
|
||||||
return addRecord(formData, context);
|
if (!newName) {
|
||||||
case 'override_dns':
|
return data({ success: false }, 400);
|
||||||
return overrideDns(formData, context);
|
}
|
||||||
default:
|
|
||||||
return data({ success: false }, 400);
|
await context.hs.patch([
|
||||||
}
|
{
|
||||||
}
|
path: "dns.base_domain",
|
||||||
|
value: newName,
|
||||||
async function renameTailnet(formData: FormData, context: LoadContext) {
|
},
|
||||||
const newName = formData.get('new_name')?.toString();
|
]);
|
||||||
if (!newName) {
|
|
||||||
return data({ success: false }, 400);
|
await context.integration?.onConfigChange(api);
|
||||||
}
|
return { message: "Tailnet renamed successfully" };
|
||||||
|
}
|
||||||
await context.hs.patch([
|
case "toggle_magic": {
|
||||||
{
|
const newState = formData.get("new_state")?.toString();
|
||||||
path: 'dns.base_domain',
|
if (!newState) {
|
||||||
value: newName,
|
return data({ success: false }, 400);
|
||||||
},
|
}
|
||||||
]);
|
|
||||||
|
await context.hs.patch([
|
||||||
await context.integration?.onConfigChange(context.client);
|
{
|
||||||
}
|
path: "dns.magic_dns",
|
||||||
|
value: newState === "enabled",
|
||||||
async function toggleMagic(formData: FormData, context: LoadContext) {
|
},
|
||||||
const newState = formData.get('new_state')?.toString();
|
]);
|
||||||
if (!newState) {
|
|
||||||
return data({ success: false }, 400);
|
await context.integration?.onConfigChange(api);
|
||||||
}
|
return { message: "Magic DNS state updated successfully" };
|
||||||
|
}
|
||||||
await context.hs.patch([
|
case "remove_ns": {
|
||||||
{
|
const config = context.hs.c!;
|
||||||
path: 'dns.magic_dns',
|
const ns = formData.get("ns")?.toString();
|
||||||
value: newState === 'enabled',
|
const splitName = formData.get("split_name")?.toString();
|
||||||
},
|
|
||||||
]);
|
if (!ns || !splitName) {
|
||||||
|
return data({ success: false }, 400);
|
||||||
await context.integration?.onConfigChange(context.client);
|
}
|
||||||
}
|
|
||||||
|
if (splitName === "global") {
|
||||||
async function removeNs(formData: FormData, context: LoadContext) {
|
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
|
||||||
const config = context.hs.c!;
|
|
||||||
const ns = formData.get('ns')?.toString();
|
await context.hs.patch([
|
||||||
const splitName = formData.get('split_name')?.toString();
|
{
|
||||||
|
path: "dns.nameservers.global",
|
||||||
if (!ns || !splitName) {
|
value: servers,
|
||||||
return data({ success: false }, 400);
|
},
|
||||||
}
|
]);
|
||||||
|
} else {
|
||||||
if (splitName === 'global') {
|
const splits = config.dns.nameservers.split;
|
||||||
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
|
const servers = splits[splitName].filter((i) => i !== ns);
|
||||||
|
|
||||||
await context.hs.patch([
|
await context.hs.patch([
|
||||||
{
|
{
|
||||||
path: 'dns.nameservers.global',
|
path: `dns.nameservers.split."${splitName}"`,
|
||||||
value: servers,
|
value: servers.length > 0 ? servers : null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
} else {
|
}
|
||||||
const splits = config.dns.nameservers.split;
|
|
||||||
const servers = splits[splitName].filter((i) => i !== ns);
|
await context.integration?.onConfigChange(api);
|
||||||
|
return { message: "Nameserver removed successfully" };
|
||||||
await context.hs.patch([
|
}
|
||||||
{
|
case "add_ns": {
|
||||||
path: `dns.nameservers.split."${splitName}"`,
|
const config = context.hs.c!;
|
||||||
value: servers.length > 0 ? servers : null,
|
const ns = formData.get("ns")?.toString();
|
||||||
},
|
const splitName = formData.get("split_name")?.toString();
|
||||||
]);
|
|
||||||
}
|
if (!ns || !splitName) {
|
||||||
|
return data({ success: false }, 400);
|
||||||
await context.integration?.onConfigChange(context.client);
|
}
|
||||||
}
|
|
||||||
|
if (splitName === "global") {
|
||||||
async function addNs(formData: FormData, context: LoadContext) {
|
const servers = config.dns.nameservers.global;
|
||||||
const config = context.hs.c!;
|
servers.push(ns);
|
||||||
const ns = formData.get('ns')?.toString();
|
|
||||||
const splitName = formData.get('split_name')?.toString();
|
await context.hs.patch([
|
||||||
|
{
|
||||||
if (!ns || !splitName) {
|
path: "dns.nameservers.global",
|
||||||
return data({ success: false }, 400);
|
value: servers,
|
||||||
}
|
},
|
||||||
|
]);
|
||||||
if (splitName === 'global') {
|
} else {
|
||||||
const servers = config.dns.nameservers.global;
|
const splits = config.dns.nameservers.split;
|
||||||
servers.push(ns);
|
const servers = splits[splitName] ?? [];
|
||||||
|
servers.push(ns);
|
||||||
await context.hs.patch([
|
|
||||||
{
|
await context.hs.patch([
|
||||||
path: 'dns.nameservers.global',
|
{
|
||||||
value: servers,
|
path: `dns.nameservers.split."${splitName}"`,
|
||||||
},
|
value: servers,
|
||||||
]);
|
},
|
||||||
} else {
|
]);
|
||||||
const splits = config.dns.nameservers.split;
|
}
|
||||||
const servers = splits[splitName] ?? [];
|
|
||||||
servers.push(ns);
|
await context.integration?.onConfigChange(api);
|
||||||
|
return { message: "Nameserver added successfully" };
|
||||||
await context.hs.patch([
|
}
|
||||||
{
|
case "remove_domain": {
|
||||||
path: `dns.nameservers.split."${splitName}"`,
|
const config = context.hs.c!;
|
||||||
value: servers,
|
const domain = formData.get("domain")?.toString();
|
||||||
},
|
if (!domain) {
|
||||||
]);
|
return data({ success: false }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
await context.integration?.onConfigChange(context.client);
|
const domains = config.dns.search_domains.filter((i) => i !== domain);
|
||||||
}
|
await context.hs.patch([
|
||||||
|
{
|
||||||
async function removeDomain(formData: FormData, context: LoadContext) {
|
path: "dns.search_domains",
|
||||||
const config = context.hs.c!;
|
value: domains,
|
||||||
const domain = formData.get('domain')?.toString();
|
},
|
||||||
if (!domain) {
|
]);
|
||||||
return data({ success: false }, 400);
|
|
||||||
}
|
await context.integration?.onConfigChange(api);
|
||||||
|
return { message: "Domain removed successfully" };
|
||||||
const domains = config.dns.search_domains.filter((i) => i !== domain);
|
}
|
||||||
await context.hs.patch([
|
case "add_domain": {
|
||||||
{
|
const config = context.hs.c!;
|
||||||
path: 'dns.search_domains',
|
const domain = formData.get("domain")?.toString();
|
||||||
value: domains,
|
if (!domain) {
|
||||||
},
|
return data({ success: false }, 400);
|
||||||
]);
|
}
|
||||||
|
|
||||||
await context.integration?.onConfigChange(context.client);
|
const domains = config.dns.search_domains;
|
||||||
}
|
domains.push(domain);
|
||||||
|
|
||||||
async function addDomain(formData: FormData, context: LoadContext) {
|
await context.hs.patch([
|
||||||
const config = context.hs.c!;
|
{
|
||||||
const domain = formData.get('domain')?.toString();
|
path: "dns.search_domains",
|
||||||
if (!domain) {
|
value: domains,
|
||||||
return data({ success: false }, 400);
|
},
|
||||||
}
|
]);
|
||||||
|
|
||||||
const domains = config.dns.search_domains;
|
await context.integration?.onConfigChange(api);
|
||||||
domains.push(domain);
|
return { message: "Domain added successfully" };
|
||||||
|
}
|
||||||
await context.hs.patch([
|
case "remove_record": {
|
||||||
{
|
const recordName = formData.get("record_name")?.toString();
|
||||||
path: 'dns.search_domains',
|
const recordType = formData.get("record_type")?.toString();
|
||||||
value: domains,
|
|
||||||
},
|
if (!recordName || !recordType) {
|
||||||
]);
|
return data({ success: false }, 400);
|
||||||
|
}
|
||||||
await context.integration?.onConfigChange(context.client);
|
|
||||||
}
|
// Value is not needed for removal
|
||||||
|
const restart = await context.hs.removeDNS({
|
||||||
async function removeRecord(formData: FormData, context: LoadContext) {
|
name: recordName,
|
||||||
const config = context.hs.c!;
|
type: recordType,
|
||||||
const recordName = formData.get('record_name')?.toString();
|
value: "",
|
||||||
const recordType = formData.get('record_type')?.toString();
|
});
|
||||||
|
|
||||||
if (!recordName || !recordType) {
|
if (!restart) {
|
||||||
return data({ success: false }, 400);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Value is not needed for removal
|
await context.integration?.onConfigChange(api);
|
||||||
const restart = await context.hs.removeDNS({
|
return { message: "DNS record removed successfully" };
|
||||||
name: recordName,
|
}
|
||||||
type: recordType,
|
case "add_record": {
|
||||||
value: '',
|
const recordName = formData.get("record_name")?.toString();
|
||||||
});
|
const recordType = formData.get("record_type")?.toString();
|
||||||
|
const recordValue = formData.get("record_value")?.toString();
|
||||||
if (!restart) {
|
|
||||||
return;
|
if (!recordName || !recordType || !recordValue) {
|
||||||
}
|
return data({ success: false }, 400);
|
||||||
|
}
|
||||||
await context.integration?.onConfigChange(context.client);
|
|
||||||
}
|
const restart = await context.hs.addDNS({
|
||||||
|
name: recordName,
|
||||||
async function addRecord(formData: FormData, context: LoadContext) {
|
type: recordType,
|
||||||
const config = context.hs.c!;
|
value: recordValue,
|
||||||
const recordName = formData.get('record_name')?.toString();
|
});
|
||||||
const recordType = formData.get('record_type')?.toString();
|
|
||||||
const recordValue = formData.get('record_value')?.toString();
|
if (!restart) {
|
||||||
|
return;
|
||||||
if (!recordName || !recordType || !recordValue) {
|
}
|
||||||
return data({ success: false }, 400);
|
|
||||||
}
|
await context.integration?.onConfigChange(api);
|
||||||
|
return { message: "DNS record added successfully" };
|
||||||
const restart = await context.hs.addDNS({
|
}
|
||||||
name: recordName,
|
case "override_dns": {
|
||||||
type: recordType,
|
const override = formData.get("override_dns")?.toString();
|
||||||
value: recordValue,
|
if (!override) {
|
||||||
});
|
return data({ success: false }, 400);
|
||||||
|
}
|
||||||
if (!restart) {
|
|
||||||
return;
|
const overrideValue = override === "true";
|
||||||
}
|
await context.hs.patch([
|
||||||
|
{
|
||||||
await context.integration?.onConfigChange(context.client);
|
path: "dns.override_local_dns",
|
||||||
}
|
value: overrideValue,
|
||||||
|
},
|
||||||
async function overrideDns(formData: FormData, context: LoadContext) {
|
]);
|
||||||
const override = formData.get('override_dns')?.toString();
|
|
||||||
if (!override) {
|
await context.integration?.onConfigChange(api);
|
||||||
return data({ success: false }, 400);
|
return { message: "DNS override updated successfully" };
|
||||||
}
|
}
|
||||||
|
default:
|
||||||
const overrideValue = override === 'true';
|
return data({ success: false }, 400);
|
||||||
await context.hs.patch([
|
}
|
||||||
{
|
|
||||||
path: 'dns.override_local_dns',
|
|
||||||
value: overrideValue,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
await context.integration?.onConfigChange(context.client);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+86
-98
@@ -1,115 +1,103 @@
|
|||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
||||||
import { useLoaderData } from 'react-router';
|
import { useLoaderData } from "react-router";
|
||||||
import Code from '~/components/Code';
|
|
||||||
import Notice from '~/components/Notice';
|
import Code from "~/components/Code";
|
||||||
import type { LoadContext } from '~/server';
|
import Notice from "~/components/Notice";
|
||||||
import { Capabilities } from '~/server/web/roles';
|
import type { LoadContext } from "~/server";
|
||||||
import ManageDomains from './components/manage-domains';
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import ManageNS from './components/manage-ns';
|
|
||||||
import ManageRecords from './components/manage-records';
|
import ManageDomains from "./components/manage-domains";
|
||||||
import RenameTailnet from './components/rename-tailnet';
|
import ManageNS from "./components/manage-ns";
|
||||||
import ToggleMagic from './components/toggle-magic';
|
import ManageRecords from "./components/manage-records";
|
||||||
import { dnsAction } from './dns-actions';
|
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
|
// We do not want to expose every config value
|
||||||
export async function loader({
|
export async function loader({ request, context }: LoaderFunctionArgs<LoadContext>) {
|
||||||
request,
|
if (!context.hs.readable()) {
|
||||||
context,
|
throw new Error("No configuration is available");
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
}
|
||||||
if (!context.hs.readable()) {
|
|
||||||
throw new Error('No configuration is available');
|
|
||||||
}
|
|
||||||
|
|
||||||
const check = await context.sessions.check(
|
const principal = await context.auth.require(request);
|
||||||
request,
|
const check = context.auth.can(principal, Capabilities.read_network);
|
||||||
Capabilities.read_network,
|
if (!check) {
|
||||||
);
|
// Not authorized to view this page
|
||||||
if (!check) {
|
throw new Error(
|
||||||
// Not authorized to view this page
|
"You do not have permission to view this page. Please contact your administrator.",
|
||||||
throw new Error(
|
);
|
||||||
'You do not have permission to view this page. Please contact your administrator.',
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const writablePermission = await context.sessions.check(
|
const writablePermission = context.auth.can(principal, Capabilities.write_network);
|
||||||
request,
|
|
||||||
Capabilities.write_network,
|
|
||||||
);
|
|
||||||
|
|
||||||
const config = context.hs.c!;
|
const config = context.hs.c!;
|
||||||
const dns = {
|
const dns = {
|
||||||
prefixes: config.prefixes,
|
prefixes: config.prefixes,
|
||||||
magicDns: config.dns.magic_dns,
|
magicDns: config.dns.magic_dns,
|
||||||
baseDomain: config.dns.base_domain,
|
baseDomain: config.dns.base_domain,
|
||||||
nameservers: config.dns.nameservers.global,
|
nameservers: config.dns.nameservers.global,
|
||||||
splitDns: config.dns.nameservers.split,
|
splitDns: config.dns.nameservers.split,
|
||||||
searchDomains: config.dns.search_domains,
|
searchDomains: config.dns.search_domains,
|
||||||
overrideDns: config.dns.override_local_dns,
|
overrideDns: config.dns.override_local_dns,
|
||||||
extraRecords: context.hs.d,
|
extraRecords: context.hs.d,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...dns,
|
...dns,
|
||||||
access: writablePermission,
|
access: writablePermission,
|
||||||
writable: context.hs.writable(),
|
writable: context.hs.writable(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action(data: ActionFunctionArgs) {
|
export async function action(data: ActionFunctionArgs) {
|
||||||
return dnsAction(data);
|
return dnsAction(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const data = useLoaderData<typeof loader>();
|
const data = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
const allNs: Record<string, string[]> = {};
|
const allNs: Record<string, string[]> = {};
|
||||||
for (const key of Object.keys(data.splitDns)) {
|
for (const key of Object.keys(data.splitDns)) {
|
||||||
allNs[key] = data.splitDns[key];
|
allNs[key] = data.splitDns[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
allNs.global = data.nameservers;
|
allNs.global = data.nameservers;
|
||||||
const isDisabled = data.access === false || data.writable === false;
|
const isDisabled = data.access === false || data.writable === false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-16 max-w-(--breakpoint-lg)">
|
<div className="flex max-w-(--breakpoint-lg) flex-col gap-16">
|
||||||
{data.writable ? undefined : (
|
{data.writable ? undefined : (
|
||||||
<Notice>
|
<Notice>
|
||||||
The Headscale configuration is read-only. You cannot make changes to
|
The Headscale configuration is read-only. You cannot make changes to the configuration
|
||||||
the configuration
|
</Notice>
|
||||||
</Notice>
|
)}
|
||||||
)}
|
{data.access ? undefined : (
|
||||||
{data.access ? undefined : (
|
<Notice>
|
||||||
<Notice>
|
Your permissions do not allow you to modify the DNS settings for this tailnet.
|
||||||
Your permissions do not allow you to modify the DNS settings for this
|
</Notice>
|
||||||
tailnet.
|
)}
|
||||||
</Notice>
|
<RenameTailnet isDisabled={isDisabled} name={data.baseDomain} />
|
||||||
)}
|
<ManageNS isDisabled={isDisabled} nameservers={allNs} overrideLocalDns={data.overrideDns} />
|
||||||
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
|
<ManageRecords isDisabled={isDisabled} records={data.extraRecords} />
|
||||||
<ManageNS
|
<ManageDomains
|
||||||
nameservers={allNs}
|
isDisabled={isDisabled}
|
||||||
isDisabled={isDisabled}
|
magic={data.magicDns ? data.baseDomain : undefined}
|
||||||
overrideLocalDns={data.overrideDns}
|
searchDomains={data.searchDomains}
|
||||||
/>
|
/>
|
||||||
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
|
|
||||||
<ManageDomains
|
|
||||||
searchDomains={data.searchDomains}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
magic={data.magicDns ? data.baseDomain : undefined}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex w-full flex-col sm:w-2/3">
|
||||||
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
|
<h1 className="mb-4 text-2xl font-medium">Magic DNS</h1>
|
||||||
<p className="mb-4">
|
<p className="mb-4">
|
||||||
Automatically register domain names for each device on the tailnet.
|
Automatically register domain names for each device on the tailnet. Devices will be
|
||||||
Devices will be accessible at{' '}
|
accessible at{" "}
|
||||||
<Code>
|
<Code>
|
||||||
[device].
|
[device].
|
||||||
{data.baseDomain}
|
{data.baseDomain}
|
||||||
</Code>{' '}
|
</Code>{" "}
|
||||||
when Magic DNS is enabled.
|
when Magic DNS is enabled.
|
||||||
</p>
|
</p>
|
||||||
<ToggleMagic isEnabled={data.magicDns} isDisabled={isDisabled} />
|
<ToggleMagic isDisabled={isDisabled} isEnabled={data.magicDns} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,222 +1,214 @@
|
|||||||
import { ChevronDown, Copy } from 'lucide-react';
|
import { ChevronDown, Copy } from "lucide-react";
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from "react";
|
||||||
import { Link } from 'react-router';
|
import { Link } from "react-router";
|
||||||
import Chip from '~/components/Chip';
|
|
||||||
import Menu from '~/components/Menu';
|
import Chip from "~/components/Chip";
|
||||||
import StatusCircle from '~/components/StatusCircle';
|
import Menu from "~/components/Menu";
|
||||||
import { ExitNodeTag } from '~/components/tags/ExitNode';
|
import StatusCircle from "~/components/StatusCircle";
|
||||||
import { ExpiryTag } from '~/components/tags/Expiry';
|
import { ExitNodeTag } from "~/components/tags/ExitNode";
|
||||||
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
|
import { ExpiryTag } from "~/components/tags/Expiry";
|
||||||
import { SubnetTag } from '~/components/tags/Subnet';
|
import { HeadplaneAgentTag } from "~/components/tags/HeadplaneAgent";
|
||||||
import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH';
|
import { SubnetTag } from "~/components/tags/Subnet";
|
||||||
import type { User } from '~/types';
|
import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
|
||||||
import cn from '~/utils/cn';
|
import type { User } from "~/types";
|
||||||
import * as hinfo from '~/utils/host-info';
|
import cn from "~/utils/cn";
|
||||||
import { PopulatedNode } from '~/utils/node-info';
|
import * as hinfo from "~/utils/host-info";
|
||||||
import toast from '~/utils/toast';
|
import { PopulatedNode } from "~/utils/node-info";
|
||||||
import MenuOptions from './menu';
|
import { formatTimeDelta } from "~/utils/time";
|
||||||
|
import toast from "~/utils/toast";
|
||||||
|
import { getUserDisplayName } from "~/utils/user";
|
||||||
|
|
||||||
|
import MenuOptions from "./menu";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
node: PopulatedNode;
|
node: PopulatedNode;
|
||||||
users: User[];
|
users: User[];
|
||||||
isAgent?: boolean;
|
isAgent?: boolean;
|
||||||
magic?: string;
|
magic?: string;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
|
existingTags?: string[];
|
||||||
|
supportsNodeOwnerChange: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MachineRow({
|
export default function MachineRow({
|
||||||
node,
|
node,
|
||||||
users,
|
users,
|
||||||
isAgent,
|
isAgent,
|
||||||
magic,
|
magic,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
|
existingTags,
|
||||||
|
supportsNodeOwnerChange,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const uiTags = useMemo(() => {
|
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
|
||||||
const tags = uiTagsForNode(node, isAgent);
|
|
||||||
return tags;
|
|
||||||
}, [node, isAgent]);
|
|
||||||
|
|
||||||
const ipOptions = useMemo(() => {
|
const ipOptions = useMemo(() => {
|
||||||
if (magic) {
|
if (magic) {
|
||||||
return [...node.ipAddresses, `${node.givenName}.${magic}`];
|
return [...node.ipAddresses, `${node.givenName}.${magic}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
return node.ipAddresses;
|
return node.ipAddresses;
|
||||||
}, [magic, node.ipAddresses]);
|
}, [magic, node.ipAddresses]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={node.id}>
|
||||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
<td className="py-2 pl-0.5 focus-within:ring-3">
|
||||||
key={node.id}
|
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
|
||||||
>
|
<p
|
||||||
<td className="pl-0.5 py-2 focus-within:ring-3">
|
className={cn(
|
||||||
<Link
|
"font-semibold leading-snug",
|
||||||
className={cn('group/link h-full focus:outline-hidden')}
|
"group-hover/link:text-blue-600",
|
||||||
to={`/machines/${node.id}`}
|
"dark:group-hover/link:text-blue-400",
|
||||||
>
|
)}
|
||||||
<p
|
>
|
||||||
className={cn(
|
{node.givenName}
|
||||||
'font-semibold leading-snug',
|
</p>
|
||||||
'group-hover/link:text-blue-600',
|
<p className="text-sm opacity-50">
|
||||||
'dark:group-hover/link:text-blue-400',
|
{node.user ? getUserDisplayName(node.user) : "Tag-owned"}
|
||||||
)}
|
</p>
|
||||||
>
|
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||||
{node.givenName}
|
{mapTagsToComponents(node, uiTags)}
|
||||||
</p>
|
{node.tags?.map((tag) => (
|
||||||
<p className="text-sm opacity-50">
|
<Chip key={tag} text={tag} />
|
||||||
{node.user.name || node.user.displayName || node.user.email || node.user.id}
|
))}
|
||||||
</p>
|
</div>
|
||||||
<div className="flex gap-1 flex-wrap mt-1.5">
|
</Link>
|
||||||
{mapTagsToComponents(node, uiTags)}
|
</td>
|
||||||
{node.validTags.map((tag) => (
|
<td className="py-2">
|
||||||
<Chip key={tag} text={tag} />
|
<div className="flex items-center gap-x-1">
|
||||||
))}
|
{node.ipAddresses[0]}
|
||||||
</div>
|
<Menu placement="bottom end">
|
||||||
</Link>
|
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
||||||
</td>
|
<ChevronDown className="h-4 w-4" />
|
||||||
<td className="py-2">
|
</Menu.IconButton>
|
||||||
<div className="flex items-center gap-x-1">
|
<Menu.Panel
|
||||||
{node.ipAddresses[0]}
|
onAction={async (key) => {
|
||||||
<Menu placement="bottom end">
|
await navigator.clipboard.writeText(key.toString());
|
||||||
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
toast("Copied IP address to clipboard");
|
||||||
<ChevronDown className="w-4 h-4" />
|
}}
|
||||||
</Menu.IconButton>
|
>
|
||||||
<Menu.Panel
|
<Menu.Section>
|
||||||
onAction={async (key) => {
|
{ipOptions.map((ip) => (
|
||||||
await navigator.clipboard.writeText(key.toString());
|
<Menu.Item key={ip} textValue={ip}>
|
||||||
toast('Copied IP address to clipboard');
|
<div
|
||||||
}}
|
className={cn("flex items-center justify-between", "text-sm w-full gap-x-6")}
|
||||||
>
|
>
|
||||||
<Menu.Section>
|
{ip}
|
||||||
{ipOptions.map((ip) => (
|
<Copy className="h-3 w-3" />
|
||||||
<Menu.Item key={ip} textValue={ip}>
|
</div>
|
||||||
<div
|
</Menu.Item>
|
||||||
className={cn(
|
))}
|
||||||
'flex items-center justify-between',
|
</Menu.Section>
|
||||||
'text-sm w-full gap-x-6',
|
</Menu.Panel>
|
||||||
)}
|
</Menu>
|
||||||
>
|
</div>
|
||||||
{ip}
|
</td>
|
||||||
<Copy className="w-3 h-3" />
|
{/* We pass undefined when agents are not enabled */}
|
||||||
</div>
|
{isAgent !== undefined ? (
|
||||||
</Menu.Item>
|
<td className="py-2">
|
||||||
))}
|
{node.hostInfo !== undefined ? (
|
||||||
</Menu.Section>
|
<>
|
||||||
</Menu.Panel>
|
<p className="leading-snug">{hinfo.getTSVersion(node.hostInfo)}</p>
|
||||||
</Menu>
|
<p className="max-w-48 truncate text-sm opacity-50">
|
||||||
</div>
|
{hinfo.getOSInfo(node.hostInfo)}
|
||||||
</td>
|
</p>
|
||||||
{/* We pass undefined when agents are not enabled */}
|
</>
|
||||||
{isAgent !== undefined ? (
|
) : (
|
||||||
<td className="py-2">
|
<p className="text-sm opacity-50">Unknown</p>
|
||||||
{node.hostInfo !== undefined ? (
|
)}
|
||||||
<>
|
</td>
|
||||||
<p className="leading-snug">
|
) : undefined}
|
||||||
{hinfo.getTSVersion(node.hostInfo)}
|
<td className="py-2">
|
||||||
</p>
|
<div className="flex items-start gap-x-1">
|
||||||
<p className="text-sm opacity-50 max-w-48 truncate">
|
<StatusCircle className="mt-0.5 h-4 w-4" isOnline={node.online && !node.expired} />
|
||||||
{hinfo.getOSInfo(node.hostInfo)}
|
<div>
|
||||||
</p>
|
<p
|
||||||
</>
|
className={cn("text-sm", "text-headplane-600 dark:text-headplane-300")}
|
||||||
) : (
|
suppressHydrationWarning
|
||||||
<p className="text-sm opacity-50">Unknown</p>
|
>
|
||||||
)}
|
{node.online && !node.expired
|
||||||
</td>
|
? "Connected"
|
||||||
) : undefined}
|
: new Date(node.lastSeen).toLocaleString()}
|
||||||
<td className="py-2">
|
</p>
|
||||||
<span
|
{!(node.online && !node.expired) && (
|
||||||
className={cn(
|
<p className="text-xs opacity-50" suppressHydrationWarning>
|
||||||
'flex items-center gap-x-1 text-sm',
|
{formatTimeDelta(new Date(node.lastSeen))}
|
||||||
'text-headplane-600 dark:text-headplane-300',
|
</p>
|
||||||
)}
|
)}
|
||||||
>
|
</div>
|
||||||
<StatusCircle
|
</div>
|
||||||
className="w-4 h-4"
|
</td>
|
||||||
isOnline={node.online && !node.expired}
|
<td className="py-2 pr-0.5">
|
||||||
/>
|
<MenuOptions
|
||||||
<p suppressHydrationWarning>
|
existingTags={existingTags}
|
||||||
{node.online && !node.expired
|
isDisabled={isDisabled}
|
||||||
? 'Connected'
|
magic={magic}
|
||||||
: new Date(node.lastSeen).toLocaleString()}
|
node={node}
|
||||||
</p>
|
users={users}
|
||||||
</span>
|
supportsNodeOwnerChange={supportsNodeOwnerChange}
|
||||||
</td>
|
/>
|
||||||
<td className="py-2 pr-0.5">
|
</td>
|
||||||
<MenuOptions
|
</tr>
|
||||||
isDisabled={isDisabled}
|
);
|
||||||
magic={magic}
|
|
||||||
node={node}
|
|
||||||
users={users}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
|
export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
|
||||||
const uiTags: string[] = [];
|
const uiTags: string[] = [];
|
||||||
if (node.expired) {
|
if (node.expired) {
|
||||||
uiTags.push('expired');
|
uiTags.push("expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.expiry === null) {
|
if (node.expiry === null) {
|
||||||
uiTags.push('no-expiry');
|
uiTags.push("no-expiry");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.customRouting.exitRoutes.length > 0) {
|
if (node.customRouting.exitRoutes.length > 0) {
|
||||||
if (node.customRouting.exitApproved) {
|
if (node.customRouting.exitApproved) {
|
||||||
uiTags.push('exit-approved');
|
uiTags.push("exit-approved");
|
||||||
} else {
|
} else {
|
||||||
uiTags.push('exit-waiting');
|
uiTags.push("exit-waiting");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.customRouting.subnetWaitingRoutes.length > 0) {
|
if (node.customRouting.subnetWaitingRoutes.length > 0) {
|
||||||
uiTags.push('subnet-waiting');
|
uiTags.push("subnet-waiting");
|
||||||
} else if (node.customRouting.subnetApprovedRoutes.length > 0) {
|
} else if (node.customRouting.subnetApprovedRoutes.length > 0) {
|
||||||
uiTags.push('subnet-approved');
|
uiTags.push("subnet-approved");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
|
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
|
||||||
uiTags.push('tailscale-ssh');
|
uiTags.push("tailscale-ssh");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isAgent === true) {
|
if (isAgent === true) {
|
||||||
uiTags.push('headplane-agent');
|
uiTags.push("headplane-agent");
|
||||||
}
|
}
|
||||||
|
|
||||||
return uiTags;
|
return uiTags;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
|
export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
|
||||||
return uiTags.map((tag) => {
|
return uiTags.map((tag) => {
|
||||||
switch (tag) {
|
switch (tag) {
|
||||||
case 'exit-approved':
|
case "exit-approved":
|
||||||
case 'exit-waiting':
|
case "exit-waiting":
|
||||||
return <ExitNodeTag isEnabled={tag === 'exit-approved'} key={tag} />;
|
return <ExitNodeTag isEnabled={tag === "exit-approved"} key={tag} />;
|
||||||
|
|
||||||
case 'subnet-approved':
|
case "subnet-approved":
|
||||||
case 'subnet-waiting':
|
case "subnet-waiting":
|
||||||
return <SubnetTag isEnabled={tag === 'subnet-approved'} key={tag} />;
|
return <SubnetTag isEnabled={tag === "subnet-approved"} key={tag} />;
|
||||||
|
|
||||||
case 'expired':
|
case "expired":
|
||||||
case 'no-expiry':
|
case "no-expiry":
|
||||||
return (
|
return <ExpiryTag expiry={node.expiry ?? undefined} key={tag} variant={tag} />;
|
||||||
<ExpiryTag
|
|
||||||
expiry={node.expiry ?? undefined}
|
|
||||||
key={tag}
|
|
||||||
variant={tag}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case 'tailscale-ssh':
|
case "tailscale-ssh":
|
||||||
return <TailscaleSSHTag key={tag} />;
|
return <TailscaleSSHTag key={tag} />;
|
||||||
|
|
||||||
case 'headplane-agent':
|
case "headplane-agent":
|
||||||
return <HeadplaneAgentTag key={tag} />;
|
return <HeadplaneAgentTag key={tag} />;
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,15 @@ import Move from '../dialogs/move';
|
|||||||
import Rename from '../dialogs/rename';
|
import Rename from '../dialogs/rename';
|
||||||
import Routes from '../dialogs/routes';
|
import Routes from '../dialogs/routes';
|
||||||
import Tags from '../dialogs/tags';
|
import Tags from '../dialogs/tags';
|
||||||
|
|
||||||
interface MenuProps {
|
interface MenuProps {
|
||||||
node: PopulatedNode;
|
node: PopulatedNode;
|
||||||
users: User[];
|
users: User[];
|
||||||
magic?: string;
|
magic?: string;
|
||||||
isFullButton?: boolean;
|
isFullButton?: boolean;
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
|
existingTags?: string[];
|
||||||
|
supportsNodeOwnerChange: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
||||||
@@ -27,6 +30,8 @@ export default function MachineMenu({
|
|||||||
users,
|
users,
|
||||||
isFullButton,
|
isFullButton,
|
||||||
isDisabled,
|
isDisabled,
|
||||||
|
existingTags,
|
||||||
|
supportsNodeOwnerChange,
|
||||||
}: MenuProps) {
|
}: MenuProps) {
|
||||||
const [modal, setModal] = useState<Modal>(null);
|
const [modal, setModal] = useState<Modal>(null);
|
||||||
const supportsTailscaleSSH =
|
const supportsTailscaleSSH =
|
||||||
@@ -36,8 +41,8 @@ export default function MachineMenu({
|
|||||||
<div className="flex items-center justify-end px-4 gap-1.5">
|
<div className="flex items-center justify-end px-4 gap-1.5">
|
||||||
{modal === 'remove' && (
|
{modal === 'remove' && (
|
||||||
<Delete
|
<Delete
|
||||||
machine={node}
|
|
||||||
isOpen={modal === 'remove'}
|
isOpen={modal === 'remove'}
|
||||||
|
machine={node}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
}}
|
}}
|
||||||
@@ -45,19 +50,19 @@ export default function MachineMenu({
|
|||||||
)}
|
)}
|
||||||
{modal === 'move' && (
|
{modal === 'move' && (
|
||||||
<Move
|
<Move
|
||||||
machine={node}
|
|
||||||
users={users}
|
|
||||||
isOpen={modal === 'move'}
|
isOpen={modal === 'move'}
|
||||||
|
machine={node}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
}}
|
}}
|
||||||
|
users={users}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{modal === 'rename' && (
|
{modal === 'rename' && (
|
||||||
<Rename
|
<Rename
|
||||||
|
isOpen={modal === 'rename'}
|
||||||
machine={node}
|
machine={node}
|
||||||
magic={magic}
|
magic={magic}
|
||||||
isOpen={modal === 'rename'}
|
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
}}
|
}}
|
||||||
@@ -65,8 +70,8 @@ export default function MachineMenu({
|
|||||||
)}
|
)}
|
||||||
{modal === 'routes' && (
|
{modal === 'routes' && (
|
||||||
<Routes
|
<Routes
|
||||||
node={node}
|
|
||||||
isOpen={modal === 'routes'}
|
isOpen={modal === 'routes'}
|
||||||
|
node={node}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
}}
|
}}
|
||||||
@@ -74,8 +79,9 @@ export default function MachineMenu({
|
|||||||
)}
|
)}
|
||||||
{modal === 'tags' && (
|
{modal === 'tags' && (
|
||||||
<Tags
|
<Tags
|
||||||
machine={node}
|
existingTags={existingTags}
|
||||||
isOpen={modal === 'tags'}
|
isOpen={modal === 'tags'}
|
||||||
|
machine={node}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
}}
|
}}
|
||||||
@@ -83,8 +89,8 @@ export default function MachineMenu({
|
|||||||
)}
|
)}
|
||||||
{node.expired && modal === 'expire' ? undefined : (
|
{node.expired && modal === 'expire' ? undefined : (
|
||||||
<Expire
|
<Expire
|
||||||
machine={node}
|
|
||||||
isOpen={modal === 'expire'}
|
isOpen={modal === 'expire'}
|
||||||
|
machine={node}
|
||||||
setIsOpen={(isOpen) => {
|
setIsOpen={(isOpen) => {
|
||||||
if (!isOpen) setModal(null);
|
if (!isOpen) setModal(null);
|
||||||
}}
|
}}
|
||||||
@@ -95,33 +101,23 @@ export default function MachineMenu({
|
|||||||
isFullButton ? (
|
isFullButton ? (
|
||||||
<Button
|
<Button
|
||||||
className="flex items-center gap-x-2"
|
className="flex items-center gap-x-2"
|
||||||
variant="heavy"
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
// We need to use JS to open the SSH URL
|
// We need to use JS to open the SSH URL
|
||||||
// in a new WINDOW since href can only
|
// in a new WINDOW since href can only
|
||||||
// do a new TAB.
|
// do a new TAB.
|
||||||
window.open(
|
window.open(
|
||||||
`${__PREFIX__}/ssh?hostname=${node.name}`,
|
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
||||||
'_blank',
|
'_blank',
|
||||||
'noopener,noreferrer,width=800,height=600',
|
'noopener,noreferrer,width=800,height=600',
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
|
variant="heavy"
|
||||||
>
|
>
|
||||||
<SquareTerminal className="h-5" />
|
<SquareTerminal className="h-5" />
|
||||||
<p>SSH</p>
|
<p>SSH</p>
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
onPress={() => {
|
|
||||||
// We need to use JS to open the SSH URL
|
|
||||||
// in a new WINDOW since href can only
|
|
||||||
// do a new TAB.
|
|
||||||
window.open(
|
|
||||||
`${__PREFIX__}/ssh?hostname=${node.name}`,
|
|
||||||
'_blank',
|
|
||||||
'noopener,noreferrer,width=800,height=600',
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'py-0.5 w-fit bg-transparent border-transparent',
|
'py-0.5 w-fit bg-transparent border-transparent',
|
||||||
'border group-hover:border-headplane-200',
|
'border group-hover:border-headplane-200',
|
||||||
@@ -129,6 +125,16 @@ export default function MachineMenu({
|
|||||||
'opacity-0 pointer-events-none group-hover:opacity-100',
|
'opacity-0 pointer-events-none group-hover:opacity-100',
|
||||||
'group-hover:pointer-events-auto',
|
'group-hover:pointer-events-auto',
|
||||||
)}
|
)}
|
||||||
|
onPress={() => {
|
||||||
|
// We need to use JS to open the SSH URL
|
||||||
|
// in a new WINDOW since href can only
|
||||||
|
// do a new TAB.
|
||||||
|
window.open(
|
||||||
|
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
||||||
|
'_blank',
|
||||||
|
'noopener,noreferrer,width=800,height=600',
|
||||||
|
);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
SSH
|
SSH
|
||||||
</Button>
|
</Button>
|
||||||
@@ -142,25 +148,25 @@ export default function MachineMenu({
|
|||||||
</Menu.Button>
|
</Menu.Button>
|
||||||
) : (
|
) : (
|
||||||
<Menu.IconButton
|
<Menu.IconButton
|
||||||
label="Machine Options"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'py-0.5 w-10 bg-transparent border-transparent',
|
'py-0.5 w-10 bg-transparent border-transparent',
|
||||||
'border group-hover:border-headplane-200',
|
'border group-hover:border-headplane-200',
|
||||||
'dark:group-hover:border-headplane-700',
|
'dark:group-hover:border-headplane-700',
|
||||||
)}
|
)}
|
||||||
|
label="Machine Options"
|
||||||
>
|
>
|
||||||
<Ellipsis className="h-5" />
|
<Ellipsis className="h-5" />
|
||||||
</Menu.IconButton>
|
</Menu.IconButton>
|
||||||
)}
|
)}
|
||||||
<Menu.Panel
|
<Menu.Panel
|
||||||
onAction={(key) => setModal(key as Modal)}
|
|
||||||
disabledKeys={node.expired ? ['expire'] : []}
|
disabledKeys={node.expired ? ['expire'] : []}
|
||||||
|
onAction={(key) => setModal(key as Modal)}
|
||||||
>
|
>
|
||||||
<Menu.Section>
|
<Menu.Section>
|
||||||
<Menu.Item key="rename">Edit machine name</Menu.Item>
|
<Menu.Item key="rename">Edit machine name</Menu.Item>
|
||||||
<Menu.Item key="routes">Edit route settings</Menu.Item>
|
<Menu.Item key="routes">Edit route settings</Menu.Item>
|
||||||
<Menu.Item key="tags">Edit ACL tags</Menu.Item>
|
<Menu.Item key="tags">Edit ACL tags</Menu.Item>
|
||||||
<Menu.Item key="move">Change owner</Menu.Item>
|
{supportsNodeOwnerChange && <Menu.Item key="move">Change owner</Menu.Item>}
|
||||||
</Menu.Section>
|
</Menu.Section>
|
||||||
<Menu.Section>
|
<Menu.Section>
|
||||||
<Menu.Item key="expire" textValue="Expire">
|
<Menu.Item key="expire" textValue="Expire">
|
||||||
|
|||||||
@@ -14,16 +14,16 @@ export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
|
|||||||
return (
|
return (
|
||||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Panel
|
<Dialog.Panel
|
||||||
variant="destructive"
|
|
||||||
onSubmit={() => navigate('/machines')}
|
onSubmit={() => navigate('/machines')}
|
||||||
|
variant="destructive"
|
||||||
>
|
>
|
||||||
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
|
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
This machine will be permanently removed from your network. To re-add
|
This machine will be permanently removed from your network. To re-add
|
||||||
it, you will need to reauthenticate to your tailnet from the device.
|
it, you will need to reauthenticate to your tailnet from the device.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<input type="hidden" name="action_id" value="delete" />
|
<input name="action_id" type="hidden" value="delete" />
|
||||||
<input type="hidden" name="node_id" value={machine.id} />
|
<input name="node_id" type="hidden" value={machine.id} />
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
import { Key, useState } from 'react';
|
import { Key, useState } from "react";
|
||||||
import Dialog from '~/components/Dialog';
|
|
||||||
import Select from '~/components/Select';
|
import Dialog from "~/components/Dialog";
|
||||||
import type { Machine, User } from '~/types';
|
import Select from "~/components/Select";
|
||||||
|
import type { Machine, User } from "~/types";
|
||||||
|
import { getUserDisplayName } from "~/utils/user";
|
||||||
|
|
||||||
interface MoveProps {
|
interface MoveProps {
|
||||||
machine: Machine;
|
machine: Machine;
|
||||||
users: User[];
|
users: User[];
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
||||||
const [userId, setUserId] = useState<Key | null>(null);
|
const [userId, setUserId] = useState<Key | null>(machine.user?.id ?? null);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||||
<Dialog.Panel>
|
<Dialog.Panel isDisabled={userId === machine.user?.id}>
|
||||||
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
|
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
|
||||||
<Dialog.Text>
|
<Dialog.Text>The owner of the machine is the user associated with it.</Dialog.Text>
|
||||||
The owner of the machine is the user associated with it.
|
<input name="action_id" type="hidden" value="reassign" />
|
||||||
</Dialog.Text>
|
<input name="node_id" type="hidden" value={machine.id} />
|
||||||
<input type="hidden" name="action_id" value="reassign" />
|
<input name="user_id" type="hidden" value={userId?.toString()} />
|
||||||
<input type="hidden" name="node_id" value={machine.id} />
|
<Select
|
||||||
<input type="hidden" name="user_id" value={userId?.toString()} />
|
defaultSelectedKey={machine.user?.id}
|
||||||
<Select
|
isRequired
|
||||||
isRequired
|
label="Owner"
|
||||||
label="Owner"
|
name="user"
|
||||||
name="user"
|
onSelectionChange={(key) => {
|
||||||
placeholder="Select a user"
|
setUserId(key);
|
||||||
defaultSelectedKey={machine.user.id}
|
}}
|
||||||
onSelectionChange={(key) => {
|
placeholder="Select a user"
|
||||||
setUserId(key);
|
>
|
||||||
}}
|
{users.map((user) => (
|
||||||
>
|
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
|
||||||
{users.map((user) => (
|
))}
|
||||||
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
</Select>
|
||||||
))}
|
</Dialog.Panel>
|
||||||
</Select>
|
</Dialog>
|
||||||
</Dialog.Panel>
|
);
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,86 @@
|
|||||||
import { Computer, FileKey2 } from 'lucide-react';
|
import { Computer, FileKey2 } from "lucide-react";
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from "react-router";
|
||||||
import Code from '~/components/Code';
|
|
||||||
import Dialog from '~/components/Dialog';
|
import Code from "~/components/Code";
|
||||||
import Input from '~/components/Input';
|
import Dialog from "~/components/Dialog";
|
||||||
import Menu from '~/components/Menu';
|
import Input from "~/components/Input";
|
||||||
import Select from '~/components/Select';
|
import Menu from "~/components/Menu";
|
||||||
import type { User } from '~/types';
|
import Select from "~/components/Select";
|
||||||
|
import type { User } from "~/types";
|
||||||
|
import { getUserDisplayName } from "~/utils/user";
|
||||||
|
|
||||||
export interface NewMachineProps {
|
export interface NewMachineProps {
|
||||||
server: string;
|
server: string;
|
||||||
users: User[];
|
users: User[];
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
disabledKeys?: string[];
|
disabledKeys?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NewMachine(data: NewMachineProps) {
|
export default function NewMachine(data: NewMachineProps) {
|
||||||
const [pushDialog, setPushDialog] = useState(false);
|
const [pushDialog, setPushDialog] = useState(false);
|
||||||
const [mkey, setMkey] = useState('');
|
const [mkey, setMkey] = useState("");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
const isMkeyInvalid = mkey.length > 0 && mkey.length !== 24;
|
||||||
<>
|
|
||||||
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
|
|
||||||
<Dialog.Panel isDisabled={mkey.length < 1}>
|
|
||||||
<Dialog.Title>Register Machine Key</Dialog.Title>
|
|
||||||
<Dialog.Text className="mb-4">
|
|
||||||
The machine key is given when you run{' '}
|
|
||||||
<Code isCopyable>tailscale up --login-server={data.server}</Code> on
|
|
||||||
your device.
|
|
||||||
</Dialog.Text>
|
|
||||||
<input type="hidden" name="action_id" value="register" />
|
|
||||||
<Input
|
|
||||||
isRequired
|
|
||||||
label="Machine Key"
|
|
||||||
placeholder="AbCd..."
|
|
||||||
validationBehavior="native"
|
|
||||||
name="register_key"
|
|
||||||
onChange={setMkey}
|
|
||||||
/>
|
|
||||||
<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 isDisabled={data.isDisabled} disabledKeys={data.disabledKeys}>
|
|
||||||
<Menu.Button variant="heavy">Add Device</Menu.Button>
|
|
||||||
<Menu.Panel
|
|
||||||
onAction={(key) => {
|
|
||||||
if (key === 'register') {
|
|
||||||
setPushDialog(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key === 'pre-auth') {
|
return (
|
||||||
navigate('/settings/auth-keys');
|
<>
|
||||||
}
|
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
|
||||||
}}
|
<Dialog.Panel isDisabled={mkey.length !== 24}>
|
||||||
>
|
<Dialog.Title>Register Machine Key</Dialog.Title>
|
||||||
<Menu.Section>
|
<Dialog.Text className="mb-4">
|
||||||
<Menu.Item key="register" textValue="Register Machine Key">
|
The machine key is given when you run{" "}
|
||||||
<div className="flex items-center gap-x-3">
|
<Code isCopyable>tailscale up --login-server={data.server}</Code> on your device.
|
||||||
<Computer className="w-4" />
|
</Dialog.Text>
|
||||||
Register Machine Key
|
<input name="action_id" type="hidden" value="register" />
|
||||||
</div>
|
<Input
|
||||||
</Menu.Item>
|
errorMessage="Machine key must be exactly 24 characters"
|
||||||
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
|
isInvalid={isMkeyInvalid}
|
||||||
<div className="flex items-center gap-x-3">
|
isRequired
|
||||||
<FileKey2 className="w-4" />
|
label="Machine Key"
|
||||||
Generate Pre-auth Key
|
name="register_key"
|
||||||
</div>
|
onChange={setMkey}
|
||||||
</Menu.Item>
|
placeholder="AbCd..."
|
||||||
</Menu.Section>
|
validationBehavior="native"
|
||||||
</Menu.Panel>
|
/>
|
||||||
</Menu>
|
<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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,16 +27,15 @@ export default function Rename({
|
|||||||
This name is shown in the admin panel, in Tailscale clients, and used
|
This name is shown in the admin panel, in Tailscale clients, and used
|
||||||
when generating MagicDNS names.
|
when generating MagicDNS names.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<input type="hidden" name="action_id" value="rename" />
|
<input name="action_id" type="hidden" value="rename" />
|
||||||
<input type="hidden" name="node_id" value={machine.id} />
|
<input name="node_id" type="hidden" value={machine.id} />
|
||||||
<Input
|
<Input
|
||||||
|
defaultValue={machine.givenName}
|
||||||
isRequired
|
isRequired
|
||||||
label="Machine name"
|
label="Machine name"
|
||||||
placeholder="Machine name"
|
|
||||||
validationBehavior="native"
|
|
||||||
name="name"
|
name="name"
|
||||||
defaultValue={machine.givenName}
|
|
||||||
onChange={setName}
|
onChange={setName}
|
||||||
|
placeholder="Machine name"
|
||||||
validate={(value) => {
|
validate={(value) => {
|
||||||
if (value.length === 0) {
|
if (value.length === 0) {
|
||||||
return 'Cannot be empty';
|
return 'Cannot be empty';
|
||||||
@@ -66,6 +65,7 @@ export default function Rename({
|
|||||||
return 'Cannot contain consecutive hyphens';
|
return 'Cannot contain consecutive hyphens';
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
validationBehavior="native"
|
||||||
/>
|
/>
|
||||||
{magic ? (
|
{magic ? (
|
||||||
name.length > 0 && name !== machine.givenName ? (
|
name.length > 0 && name !== machine.givenName ? (
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
|
|||||||
Connect to devices you can't install Tailscale on by advertising
|
Connect to devices you can't install Tailscale on by advertising
|
||||||
IP ranges as subnet routes.{' '}
|
IP ranges as subnet routes.{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://tailscale.com/kb/1019/subnets"
|
|
||||||
name="Tailscale Subnets Documentation"
|
name="Tailscale Subnets Documentation"
|
||||||
|
to="https://tailscale.com/kb/1019/subnets"
|
||||||
>
|
>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
@@ -70,8 +70,8 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
|
|||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
Allow your network to route internet traffic through this machine.{' '}
|
Allow your network to route internet traffic through this machine.{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://tailscale.com/kb/1103/exit-nodes"
|
|
||||||
name="Tailscale Exit-node Documentation"
|
name="Tailscale Exit-node Documentation"
|
||||||
|
to="https://tailscale.com/kb/1103/exit-nodes"
|
||||||
>
|
>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -1,101 +1,141 @@
|
|||||||
import { Plus, TagsIcon, X } from 'lucide-react';
|
import { Plus, TagsIcon, X } from "lucide-react";
|
||||||
import { useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import Button from '~/components/Button';
|
import { useFetcher } from "react-router";
|
||||||
import Dialog from '~/components/Dialog';
|
|
||||||
import Input from '~/components/Input';
|
import type { Machine } from "~/types";
|
||||||
import Link from '~/components/Link';
|
|
||||||
import TableList from '~/components/TableList';
|
import Button from "~/components/Button";
|
||||||
import type { Machine } from '~/types';
|
import Dialog from "~/components/Dialog";
|
||||||
import cn from '~/utils/cn';
|
import Link from "~/components/Link";
|
||||||
|
import Select from "~/components/Select";
|
||||||
|
import TableList from "~/components/TableList";
|
||||||
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
interface TagsProps {
|
interface TagsProps {
|
||||||
machine: Machine;
|
machine: Machine;
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
setIsOpen: (isOpen: boolean) => void;
|
setIsOpen: (isOpen: boolean) => void;
|
||||||
|
existingTags?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsProps) {
|
||||||
const [tags, setTags] = useState(machine.forcedTags);
|
const fetcher = useFetcher();
|
||||||
const [tag, setTag] = useState('');
|
const submittingRef = useRef(false);
|
||||||
|
const [tags, setTags] = useState([...machine.tags]);
|
||||||
|
const [tag, setTag] = useState("tag:");
|
||||||
|
const tagIsInvalid = useMemo(() => {
|
||||||
|
return tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag);
|
||||||
|
}, [tag, tags]);
|
||||||
|
|
||||||
return (
|
const validNodeTags = useMemo(() => {
|
||||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
|
||||||
<Dialog.Panel>
|
}, [tags]);
|
||||||
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
|
|
||||||
<Dialog.Text>
|
const error = fetcher.data && !fetcher.data.success ? fetcher.data.error : null;
|
||||||
ACL tags can be used to reference machines in your ACL policies. See
|
|
||||||
the{' '}
|
useEffect(() => {
|
||||||
<Link
|
if (fetcher.data?.success) {
|
||||||
to="https://tailscale.com/kb/1068/acl-tags"
|
submittingRef.current = false;
|
||||||
name="Tailscale documentation"
|
setIsOpen(false);
|
||||||
>
|
}
|
||||||
Tailscale documentation
|
|
||||||
</Link>{' '}
|
if (fetcher.state === "idle" && fetcher.data && !fetcher.data.success) {
|
||||||
for more information.
|
submittingRef.current = false;
|
||||||
</Dialog.Text>
|
}
|
||||||
<input type="hidden" name="action_id" value="update_tags" />
|
}, [fetcher.data, fetcher.state]);
|
||||||
<input type="hidden" name="node_id" value={machine.id} />
|
|
||||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
useEffect(() => {
|
||||||
<TableList className="mt-4">
|
if (isOpen) {
|
||||||
{tags.length === 0 ? (
|
setTags([...machine.tags]);
|
||||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
}
|
||||||
<TagsIcon />
|
}, [isOpen]);
|
||||||
<p className="font-semibold">No tags are set on this machine</p>
|
|
||||||
</TableList.Item>
|
return (
|
||||||
) : (
|
<Dialog
|
||||||
tags.map((item) => (
|
isOpen={isOpen}
|
||||||
<TableList.Item className="font-mono" key={item} id={item}>
|
onOpenChange={(open) => {
|
||||||
{item}
|
if (!open && submittingRef.current) return;
|
||||||
<Button
|
setIsOpen(open);
|
||||||
className="rounded-md p-0.5"
|
}}
|
||||||
onPress={() => {
|
>
|
||||||
setTags(tags.filter((tag) => tag !== item));
|
<Dialog.Panel
|
||||||
}}
|
onSubmit={(event) => {
|
||||||
>
|
event.preventDefault();
|
||||||
<X className="p-1" />
|
submittingRef.current = true;
|
||||||
</Button>
|
const form = new FormData();
|
||||||
</TableList.Item>
|
form.set("action_id", "update_tags");
|
||||||
))
|
form.set("node_id", machine.id);
|
||||||
)}
|
form.set("tags", tags.filter((t) => t !== "").join(","));
|
||||||
<TableList.Item
|
fetcher.submit(form, { method: "POST" });
|
||||||
className={cn(
|
}}
|
||||||
'rounded-b-xl focus-within:ring-3',
|
isDisabled={fetcher.state !== "idle"}
|
||||||
tag.length > 0 &&
|
>
|
||||||
(!tag.startsWith('tag:') || tags.includes(tag)) &&
|
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
|
||||||
'ring-3 ring-red-500 ring-opacity-50',
|
<Dialog.Text>
|
||||||
)}
|
ACL tags can be used to reference machines in your ACL policies. See the{" "}
|
||||||
>
|
<Link name="Tailscale documentation" to="https://tailscale.com/kb/1068/acl-tags">
|
||||||
<Input
|
Tailscale documentation
|
||||||
labelHidden
|
</Link>{" "}
|
||||||
label="Add a tag"
|
for more information.
|
||||||
placeholder="tag:example"
|
</Dialog.Text>
|
||||||
onChange={setTag}
|
{error ? (
|
||||||
className={cn(
|
<p className="mt-2 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
|
||||||
'border-none font-mono p-0',
|
{error}
|
||||||
'rounded-none focus:ring-0 w-full',
|
</p>
|
||||||
)}
|
) : null}
|
||||||
/>
|
<TableList className="mt-4">
|
||||||
<Button
|
{tags.length === 0 ? (
|
||||||
className={cn(
|
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||||
'rounded-md p-0.5',
|
<TagsIcon />
|
||||||
(!tag.startsWith('tag:') || tags.includes(tag)) &&
|
<p className="font-semibold">No tags are set on this machine</p>
|
||||||
'opacity-50 cursor-not-allowed',
|
</TableList.Item>
|
||||||
)}
|
) : (
|
||||||
isDisabled={
|
tags.map((item) => (
|
||||||
tag.length === 0 ||
|
<TableList.Item className="font-mono" id={item} key={item}>
|
||||||
!tag.startsWith('tag:') ||
|
{item}
|
||||||
tags.includes(tag)
|
<Button
|
||||||
}
|
className="rounded-md p-0.5"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
setTags([...tags, tag]);
|
setTags(tags.filter((tag) => tag !== item));
|
||||||
setTag('');
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<X className="p-1" />
|
||||||
<Plus className="p-1" />
|
</Button>
|
||||||
</Button>
|
</TableList.Item>
|
||||||
</TableList.Item>
|
))
|
||||||
</TableList>
|
)}
|
||||||
</Dialog.Panel>
|
</TableList>
|
||||||
</Dialog>
|
|
||||||
);
|
<div className="mt-2 flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
allowsCustomValue
|
||||||
|
aria-label="Add a tag"
|
||||||
|
className="w-full"
|
||||||
|
inputValue={tag}
|
||||||
|
isInvalid={tag.length > 0 && tagIsInvalid}
|
||||||
|
onInputChange={setTag}
|
||||||
|
placeholder="tag:example"
|
||||||
|
>
|
||||||
|
{validNodeTags.map((nodeTag) => {
|
||||||
|
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
|
||||||
|
})}
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
className={cn("rounded-md p-1", tagIsInvalid && "opacity-50 cursor-not-allowed")}
|
||||||
|
isDisabled={tagIsInvalid}
|
||||||
|
onPress={() => {
|
||||||
|
setTags([...tags, tag]);
|
||||||
|
setTag("tag:");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="p-1" size={30} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm opacity-50">
|
||||||
|
Not seeing the tags you expect? Tags need to be defined in your access control policy
|
||||||
|
before they can be assigned to machines.
|
||||||
|
</p>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,266 +1,192 @@
|
|||||||
import { type ActionFunctionArgs, data, redirect } from 'react-router';
|
import { data, redirect } from "react-router";
|
||||||
import type { LoadContext } from '~/server';
|
|
||||||
import { Capabilities } from '~/server/web/roles';
|
|
||||||
import { Machine } from '~/types';
|
|
||||||
|
|
||||||
export async function machineAction({
|
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||||
request,
|
import { Capabilities } from "~/server/web/roles";
|
||||||
context,
|
|
||||||
}: ActionFunctionArgs<LoadContext>) {
|
|
||||||
const session = await context.sessions.auth(request);
|
|
||||||
const check = await context.sessions.check(
|
|
||||||
request,
|
|
||||||
Capabilities.write_machines,
|
|
||||||
);
|
|
||||||
|
|
||||||
const formData = await request.formData();
|
import type { Route } from "./+types/machine";
|
||||||
const apiKey = session.api_key;
|
|
||||||
|
|
||||||
const action = formData.get('action_id')?.toString();
|
export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||||
if (!action) {
|
const principal = await context.auth.require(request);
|
||||||
throw data('Missing `action_id` in the form data.', {
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fast track register since it doesn't require an existing machine
|
const formData = await request.formData();
|
||||||
if (action === 'register') {
|
const api = context.hsApi.getRuntimeClient(
|
||||||
if (!check) {
|
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
|
||||||
throw data('You do not have permission to manage machines', {
|
);
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return registerMachine(formData, apiKey, context);
|
const action = formData.get("action_id")?.toString();
|
||||||
}
|
if (!action) {
|
||||||
|
throw data("Missing `action_id` in the form data.", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check if the user has permission to manage this machine
|
// Fast track register since it doesn't require an existing machine
|
||||||
const nodeId = formData.get('node_id')?.toString();
|
if (action === "register") {
|
||||||
if (!nodeId) {
|
if (!context.auth.can(principal, Capabilities.write_machines)) {
|
||||||
throw data('Missing `node_id` in the form data.', {
|
throw data("You do not have permission to manage machines", {
|
||||||
status: 400,
|
status: 403,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
const registrationKey = formData.get("register_key")?.toString();
|
||||||
'v1/node',
|
if (!registrationKey) {
|
||||||
apiKey,
|
throw data("Missing `register_key` in the form data.", {
|
||||||
);
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const node = nodes.find((node) => node.id === nodeId);
|
const user = formData.get("user")?.toString();
|
||||||
if (!node) {
|
if (!user) {
|
||||||
throw data(`Machine with ID ${nodeId} not found`, {
|
throw data("Missing `user` in the form data.", {
|
||||||
status: 404,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
const node = await api.registerNode(user, registrationKey);
|
||||||
node.user.providerId?.split('/').pop() !== session.user.subject &&
|
return redirect(`/machines/${node.id}`);
|
||||||
!check
|
}
|
||||||
) {
|
|
||||||
throw data('You do not have permission to act on this machine', {
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (action) {
|
// Check if the user has permission to manage this machine
|
||||||
case 'rename': {
|
const nodeId = formData.get("node_id")?.toString();
|
||||||
return renameMachine(formData, apiKey, nodeId, context);
|
if (!nodeId) {
|
||||||
}
|
throw data("Missing `node_id` in the form data.", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
case 'delete': {
|
const node = await api.getNode(nodeId);
|
||||||
return deleteMachine(apiKey, nodeId, context);
|
if (!node) {
|
||||||
}
|
throw data(`Machine with ID ${nodeId} not found`, {
|
||||||
|
status: 404,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
case 'expire': {
|
if (!context.auth.canManageNode(principal, node)) {
|
||||||
return expireMachine(apiKey, nodeId, context);
|
throw data("You do not have permission to act on this machine", {
|
||||||
}
|
status: 403,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
case 'update_tags': {
|
switch (action) {
|
||||||
return updateTags(formData, apiKey, nodeId, context);
|
case "rename": {
|
||||||
}
|
const newName = formData.get("name")?.toString();
|
||||||
|
if (!newName) {
|
||||||
|
throw data("Missing `name` in the form data.", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
case 'update_routes': {
|
const name = String(formData.get("name"));
|
||||||
return updateRoutes(formData, apiKey, nodeId, context);
|
await api.renameNode(nodeId, name);
|
||||||
}
|
return { message: "Machine renamed" };
|
||||||
|
}
|
||||||
|
|
||||||
case 'reassign': {
|
case "delete": {
|
||||||
return reassignMachine(formData, apiKey, nodeId, context);
|
await api.deleteNode(nodeId);
|
||||||
}
|
return redirect("/machines");
|
||||||
|
}
|
||||||
|
|
||||||
default:
|
case "expire": {
|
||||||
throw data('Invalid action', {
|
await api.expireNode(nodeId);
|
||||||
status: 400,
|
return { message: "Machine expired" };
|
||||||
});
|
}
|
||||||
}
|
|
||||||
}
|
case "update_tags": {
|
||||||
|
const tags = formData.get("tags")?.toString().split(",") ?? [];
|
||||||
async function registerMachine(
|
if (tags.length === 0) {
|
||||||
formData: FormData,
|
throw data("Missing `tags` in the form data.", {
|
||||||
apiKey: string,
|
status: 400,
|
||||||
context: LoadContext,
|
});
|
||||||
) {
|
}
|
||||||
const registrationKey = formData.get('register_key')?.toString();
|
|
||||||
if (!registrationKey) {
|
try {
|
||||||
throw data('Missing `register_key` in the form data.', {
|
await api.setNodeTags(
|
||||||
status: 400,
|
nodeId,
|
||||||
});
|
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
|
||||||
}
|
);
|
||||||
|
|
||||||
const user = formData.get('user')?.toString();
|
return { success: true as const, message: "Tags updated" };
|
||||||
if (!user) {
|
} catch (error) {
|
||||||
throw data('Missing `user` in the form data.', {
|
if (isDataWithApiError(error) && error.data.statusCode === 400) {
|
||||||
status: 400,
|
return data(
|
||||||
});
|
{
|
||||||
}
|
success: false as const,
|
||||||
|
error:
|
||||||
const qp = new URLSearchParams();
|
"One or more tags are not defined in your ACL policy. Please add them to your policy before assigning them to a machine.",
|
||||||
qp.append('user', user);
|
},
|
||||||
qp.append('key', registrationKey);
|
{ status: 400 },
|
||||||
const url = `v1/node/register?${qp.toString()}`;
|
);
|
||||||
const { node } = await context.client.post<{ node: Machine }>(url, apiKey, {
|
}
|
||||||
user,
|
|
||||||
key: registrationKey,
|
throw error;
|
||||||
});
|
}
|
||||||
|
}
|
||||||
return redirect(`/machines/${node.id}`);
|
|
||||||
}
|
case "update_routes": {
|
||||||
|
const newApproved = node.approvedRoutes;
|
||||||
async function renameMachine(
|
const routes = formData.get("routes")?.toString();
|
||||||
formData: FormData,
|
if (!routes) {
|
||||||
apiKey: string,
|
throw data("Missing `routes` in the form data.", {
|
||||||
nodeId: string,
|
status: 400,
|
||||||
context: LoadContext,
|
});
|
||||||
) {
|
}
|
||||||
const newName = formData.get('name')?.toString();
|
|
||||||
if (!newName) {
|
const allRoutes = routes.split(",").map((route) => route.trim());
|
||||||
throw data('Missing `name` in the form data.', {
|
if (allRoutes.length === 0) {
|
||||||
status: 400,
|
throw data("No routes provided to update", {
|
||||||
});
|
status: 400,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
const name = String(formData.get('name'));
|
|
||||||
await context.client.post(`v1/node/${nodeId}/rename/${name}`, apiKey);
|
const enabled = formData.get("enabled")?.toString();
|
||||||
return { message: 'Machine renamed' };
|
if (enabled === undefined) {
|
||||||
}
|
throw data("Missing `enabled` in the form data.", {
|
||||||
|
status: 400,
|
||||||
async function deleteMachine(
|
});
|
||||||
apiKey: string,
|
}
|
||||||
nodeId: string,
|
|
||||||
context: LoadContext,
|
if (enabled === "true") {
|
||||||
) {
|
for (const route of allRoutes) {
|
||||||
await context.client.delete(`v1/node/${nodeId}`, apiKey);
|
// If already approved, skip, otherwise add to approved
|
||||||
return redirect('/machines');
|
if (newApproved.includes(route)) {
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
async function expireMachine(
|
|
||||||
apiKey: string,
|
newApproved.push(route);
|
||||||
nodeId: string,
|
}
|
||||||
context: LoadContext,
|
} else {
|
||||||
) {
|
for (const route of allRoutes) {
|
||||||
await context.client.post(`v1/node/${nodeId}/expire`, apiKey);
|
// If not approved, skip, otherwise remove from approved
|
||||||
return { message: 'Machine expired' };
|
if (!newApproved.includes(route)) {
|
||||||
}
|
continue;
|
||||||
|
}
|
||||||
async function updateTags(
|
|
||||||
formData: FormData,
|
const index = newApproved.indexOf(route);
|
||||||
apiKey: string,
|
if (index > -1) {
|
||||||
nodeId: string,
|
newApproved.splice(index, 1);
|
||||||
context: LoadContext,
|
}
|
||||||
) {
|
}
|
||||||
const tags = formData.get('tags')?.toString().split(',') ?? [];
|
}
|
||||||
if (tags.length === 0) {
|
|
||||||
throw data('Missing `tags` in the form data.', {
|
await api.approveNodeRoutes(nodeId, newApproved);
|
||||||
status: 400,
|
return { message: "Routes updated" };
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
case "reassign": {
|
||||||
await context.client.post(`v1/node/${nodeId}/tags`, apiKey, {
|
const user = formData.get("user_id")?.toString();
|
||||||
tags: tags.map((tag) => tag.trim()).filter((tag) => tag !== ''),
|
if (!user) {
|
||||||
});
|
throw data("Missing `user_id` in the form data.", {
|
||||||
|
status: 400,
|
||||||
return { message: 'Tags updated' };
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function updateRoutes(
|
await api.setNodeUser(nodeId, user);
|
||||||
formData: FormData,
|
return { message: "Machine reassigned" };
|
||||||
apiKey: string,
|
}
|
||||||
nodeId: string,
|
|
||||||
context: LoadContext,
|
default:
|
||||||
) {
|
throw data("Invalid action", {
|
||||||
const { node } = await context.client.get<{ node: Machine }>(
|
status: 400,
|
||||||
`v1/node/${nodeId}`,
|
});
|
||||||
apiKey,
|
}
|
||||||
);
|
|
||||||
|
|
||||||
const newApproved = node.approvedRoutes;
|
|
||||||
const routes = formData.get('routes')?.toString();
|
|
||||||
if (!routes) {
|
|
||||||
throw data('Missing `routes` in the form data.', {
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const allRoutes = routes.split(',').map((route) => route.trim());
|
|
||||||
if (allRoutes.length === 0) {
|
|
||||||
throw data('No routes provided to update', {
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const enabled = formData.get('enabled')?.toString();
|
|
||||||
if (enabled === undefined) {
|
|
||||||
throw data('Missing `enabled` in the form data.', {
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enabled === 'true') {
|
|
||||||
for (const route of allRoutes) {
|
|
||||||
// If already approved, skip, otherwise add to approved
|
|
||||||
if (newApproved.includes(route)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
newApproved.push(route);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
for (const route of allRoutes) {
|
|
||||||
// If not approved, skip, otherwise remove from approved
|
|
||||||
if (!newApproved.includes(route)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const index = newApproved.indexOf(route);
|
|
||||||
if (index > -1) {
|
|
||||||
newApproved.splice(index, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.client.post(`v1/node/${nodeId}/approve_routes`, apiKey, {
|
|
||||||
routes: newApproved,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { message: 'Routes updated' };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reassignMachine(
|
|
||||||
formData: FormData,
|
|
||||||
apiKey: string,
|
|
||||||
nodeId: string,
|
|
||||||
context: LoadContext,
|
|
||||||
) {
|
|
||||||
const user = formData.get('user_id')?.toString();
|
|
||||||
if (!user) {
|
|
||||||
throw data('Missing `user_id` in the form data.', {
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
await context.client.post(`v1/node/${nodeId}/user`, apiKey, {
|
|
||||||
user,
|
|
||||||
});
|
|
||||||
|
|
||||||
return { message: 'Machine reassigned' };
|
|
||||||
}
|
}
|
||||||
|
|||||||
+339
-390
@@ -1,409 +1,358 @@
|
|||||||
import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
|
import { CheckCircle, CircleSlash, Info, UserCircle } from "lucide-react";
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from "react";
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
import { data, Link as RemixLink } from "react-router";
|
||||||
import { Link as RemixLink, useLoaderData } from 'react-router';
|
|
||||||
import Attribute from '~/components/Attribute';
|
|
||||||
import Button from '~/components/Button';
|
|
||||||
import Card from '~/components/Card';
|
|
||||||
import Chip from '~/components/Chip';
|
|
||||||
import Link from '~/components/Link';
|
|
||||||
import StatusCircle from '~/components/StatusCircle';
|
|
||||||
import Tooltip from '~/components/Tooltip';
|
|
||||||
import type { LoadContext } from '~/server';
|
|
||||||
import type { Machine, User } from '~/types';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
import { getOSInfo, getTSVersion } from '~/utils/host-info';
|
|
||||||
import { mapNodes } from '~/utils/node-info';
|
|
||||||
import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
|
|
||||||
import MenuOptions from './components/menu';
|
|
||||||
import Routes from './dialogs/routes';
|
|
||||||
import { machineAction } from './machine-actions';
|
|
||||||
|
|
||||||
export async function loader({
|
import Attribute from "~/components/Attribute";
|
||||||
request,
|
import Button from "~/components/Button";
|
||||||
params,
|
import Card from "~/components/Card";
|
||||||
context,
|
import Chip from "~/components/Chip";
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
import Link from "~/components/Link";
|
||||||
const session = await context.sessions.auth(request);
|
import StatusCircle from "~/components/StatusCircle";
|
||||||
if (!params.id) {
|
import Tooltip from "~/components/Tooltip";
|
||||||
throw new Error('No machine ID provided');
|
import cn from "~/utils/cn";
|
||||||
}
|
import { getOSInfo, getTSVersion } from "~/utils/host-info";
|
||||||
|
import { mapNodes, sortNodeTags } from "~/utils/node-info";
|
||||||
|
import { getUserDisplayName } from "~/utils/user";
|
||||||
|
|
||||||
let magic: string | undefined;
|
import type { Route } from "./+types/machine";
|
||||||
if (context.hs.readable()) {
|
import { mapTagsToComponents, uiTagsForNode } from "./components/machine-row";
|
||||||
if (context.hs.c?.dns.magic_dns) {
|
import MenuOptions from "./components/menu";
|
||||||
magic = context.hs.c.dns.base_domain;
|
import Routes from "./dialogs/routes";
|
||||||
}
|
import { machineAction } from "./machine-actions";
|
||||||
}
|
|
||||||
|
|
||||||
const [machine, { users }] = await Promise.all([
|
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||||
context.client.get<{ node: Machine }>(
|
const principal = await context.auth.require(request);
|
||||||
`v1/node/${params.id}`,
|
if (!params.id) {
|
||||||
session.api_key,
|
throw new Error("No machine ID provided");
|
||||||
),
|
}
|
||||||
context.client.get<{ users: User[] }>('v1/user', session.api_key),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const lookup = await context.agents?.lookup([machine.node.nodeKey]);
|
if (params.id.endsWith(".ico")) {
|
||||||
const [node] = mapNodes([machine.node], lookup);
|
throw data(null, { status: 204 });
|
||||||
const tags = Array.from(
|
}
|
||||||
new Set([...node.validTags, ...node.forcedTags]),
|
|
||||||
).sort();
|
|
||||||
|
|
||||||
return {
|
let magic: string | undefined;
|
||||||
node,
|
if (context.hs.readable()) {
|
||||||
tags,
|
if (context.hs.c?.dns.magic_dns) {
|
||||||
users,
|
magic = context.hs.c.dns.base_domain;
|
||||||
magic,
|
}
|
||||||
agent: context.agents?.agentID(),
|
}
|
||||||
stats: lookup?.[node.nodeKey],
|
|
||||||
};
|
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");
|
||||||
|
|
||||||
|
return {
|
||||||
|
node: enhancedNode,
|
||||||
|
tags,
|
||||||
|
users,
|
||||||
|
magic,
|
||||||
|
agent: context.agents?.agentID(),
|
||||||
|
stats: lookup?.[enhancedNode.nodeKey],
|
||||||
|
existingTags: sortNodeTags(nodes),
|
||||||
|
supportsNodeOwnerChange: supportsNodeOwnerChange,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action(request: ActionFunctionArgs) {
|
export const action = machineAction;
|
||||||
return machineAction(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page({
|
||||||
const { node, tags, magic, users, agent, stats } =
|
loaderData: { node, tags, users, magic, agent, stats, existingTags, supportsNodeOwnerChange },
|
||||||
useLoaderData<typeof loader>();
|
}: Route.ComponentProps) {
|
||||||
const [showRouting, setShowRouting] = useState(false);
|
const [showRouting, setShowRouting] = useState(false);
|
||||||
|
|
||||||
const uiTags = useMemo(() => {
|
const uiTags = useMemo(() => {
|
||||||
const tags = uiTagsForNode(node, agent === node.nodeKey);
|
const tags = uiTagsForNode(node, agent === node.nodeKey);
|
||||||
return tags;
|
return tags;
|
||||||
}, [node, agent]);
|
}, [node, agent]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-8 text-md">
|
<p className="text-md mb-8">
|
||||||
<RemixLink className="font-medium" to="/machines">
|
<RemixLink className="font-medium" to="/machines">
|
||||||
All Machines
|
All Machines
|
||||||
</RemixLink>
|
</RemixLink>
|
||||||
<span className="mx-2">/</span>
|
<span className="mx-2">/</span>
|
||||||
{node.givenName}
|
{node.givenName}
|
||||||
</p>
|
</p>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex justify-between items-center pb-2',
|
"flex justify-between items-center pb-2",
|
||||||
'border-b border-headplane-100 dark:border-headplane-800',
|
"border-b border-headplane-100 dark:border-headplane-800",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span className="flex items-baseline gap-x-4 text-sm">
|
<span className="flex items-baseline gap-x-4 text-sm">
|
||||||
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
||||||
<StatusCircle className="w-4 h-4" isOnline={node.online} />
|
<StatusCircle className="h-4 w-4" isOnline={node.online} />
|
||||||
</span>
|
</span>
|
||||||
<MenuOptions isFullButton magic={magic} node={node} users={users} />
|
<MenuOptions
|
||||||
</div>
|
existingTags={existingTags}
|
||||||
<div className="flex gap-1 mb-4">
|
isFullButton
|
||||||
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
magic={magic}
|
||||||
<span className="text-sm text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
node={node}
|
||||||
Managed by
|
users={users}
|
||||||
<Tooltip>
|
supportsNodeOwnerChange={supportsNodeOwnerChange}
|
||||||
<Info className="p-1" />
|
/>
|
||||||
<Tooltip.Body>
|
</div>
|
||||||
By default, a machine’s permissions match its creator’s.
|
<div className="mb-4 flex gap-1">
|
||||||
</Tooltip.Body>
|
<div className="border-headplane-100 dark:border-headplane-800 border-r p-2 pr-4">
|
||||||
</Tooltip>
|
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1 text-sm">
|
||||||
</span>
|
Managed by
|
||||||
<div className="flex items-center gap-x-2.5 mt-1">
|
<Tooltip>
|
||||||
<UserCircle />
|
<Info className="p-1" />
|
||||||
{node.user.name ||
|
<Tooltip.Body>By default, a machine’s permissions match its creator’s.</Tooltip.Body>
|
||||||
node.user.displayName ||
|
</Tooltip>
|
||||||
node.user.email ||
|
</span>
|
||||||
node.user.id}
|
<div className="mt-1 flex items-center gap-x-2.5">
|
||||||
</div>
|
<UserCircle />
|
||||||
</div>
|
{node.user ? getUserDisplayName(node.user) : "Tag-owned"}
|
||||||
<div className="p-2 pl-4">
|
</div>
|
||||||
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
</div>
|
||||||
Status
|
<div className="p-2 pl-4">
|
||||||
</p>
|
<p className="text-headplane-600 dark:text-headplane-300 text-sm">Status</p>
|
||||||
<div className="flex gap-1 mt-1 mb-8">
|
<div className="mt-1 mb-8 flex gap-1">
|
||||||
{mapTagsToComponents(node, uiTags)}
|
{mapTagsToComponents(node, uiTags)}
|
||||||
{tags.map((tag) => (
|
{tags.map((tag) => (
|
||||||
<Chip key={tag} text={tag} />
|
<Chip key={tag} text={tag} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
|
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
|
||||||
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
|
<h2 className="mt-8 text-xl font-medium">Subnets & Routing</h2>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<p>
|
<p>
|
||||||
Subnets let you expose physical network routes onto Tailscale.{' '}
|
Subnets let you expose physical network routes onto Tailscale.{" "}
|
||||||
<Link
|
<Link name="Tailscale Subnets Documentation" to="https://tailscale.com/kb/1019/subnets">
|
||||||
name="Tailscale Subnets Documentation"
|
Learn More
|
||||||
to="https://tailscale.com/kb/1019/subnets"
|
</Link>
|
||||||
>
|
</p>
|
||||||
Learn More
|
<Button onPress={() => setShowRouting(true)}>Review</Button>
|
||||||
</Link>
|
</div>
|
||||||
</p>
|
<Card
|
||||||
<Button onPress={() => setShowRouting(true)}>Review</Button>
|
className={cn(
|
||||||
</div>
|
"w-full max-w-full grid sm:grid-cols-2",
|
||||||
<Card
|
"md:grid-cols-4 gap-8 mr-2 text-sm mb-8",
|
||||||
className={cn(
|
)}
|
||||||
'w-full max-w-full grid sm:grid-cols-2',
|
variant="flat"
|
||||||
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
|
>
|
||||||
)}
|
<div>
|
||||||
variant="flat"
|
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||||
>
|
Approved
|
||||||
<div>
|
<Tooltip>
|
||||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
<Info className="h-3.5 w-3.5" />
|
||||||
Approved
|
<Tooltip.Body>
|
||||||
<Tooltip>
|
Traffic to these routes are being routed through this machine.
|
||||||
<Info className="w-3.5 h-3.5" />
|
</Tooltip.Body>
|
||||||
<Tooltip.Body>
|
</Tooltip>
|
||||||
Traffic to these routes are being routed through this machine.
|
</span>
|
||||||
</Tooltip.Body>
|
<div className="mt-1">
|
||||||
</Tooltip>
|
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
|
||||||
</span>
|
<span className="opacity-50">—</span>
|
||||||
<div className="mt-1">
|
) : (
|
||||||
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
|
<ul className="leading-normal">
|
||||||
<span className="opacity-50">—</span>
|
{node.customRouting.subnetApprovedRoutes.map((route) => (
|
||||||
) : (
|
<li key={route}>{route}</li>
|
||||||
<ul className="leading-normal">
|
))}
|
||||||
{node.customRouting.subnetApprovedRoutes.map((route) => (
|
</ul>
|
||||||
<li key={route}>{route}</li>
|
)}
|
||||||
))}
|
</div>
|
||||||
</ul>
|
<Button
|
||||||
)}
|
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
|
||||||
</div>
|
onPress={() => setShowRouting(true)}
|
||||||
<Button
|
>
|
||||||
className={cn(
|
Edit
|
||||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
</Button>
|
||||||
'text-blue-500 dark:text-blue-400',
|
</div>
|
||||||
)}
|
<div>
|
||||||
onPress={() => setShowRouting(true)}
|
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||||
>
|
Awaiting Approval
|
||||||
Edit
|
<Tooltip>
|
||||||
</Button>
|
<Info className="h-3.5 w-3.5" />
|
||||||
</div>
|
<Tooltip.Body>
|
||||||
<div>
|
This machine is advertising these routes, but they must be approved before traffic
|
||||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
will be routed to them.
|
||||||
Awaiting Approval
|
</Tooltip.Body>
|
||||||
<Tooltip>
|
</Tooltip>
|
||||||
<Info className="w-3.5 h-3.5" />
|
</span>
|
||||||
<Tooltip.Body>
|
<div className="mt-1">
|
||||||
This machine is advertising these routes, but they must be
|
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
|
||||||
approved before traffic will be routed to them.
|
<span className="opacity-50">—</span>
|
||||||
</Tooltip.Body>
|
) : (
|
||||||
</Tooltip>
|
<ul className="leading-normal">
|
||||||
</span>
|
{node.customRouting.subnetWaitingRoutes.map((route) => (
|
||||||
<div className="mt-1">
|
<li key={route}>{route}</li>
|
||||||
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
|
))}
|
||||||
<span className="opacity-50">—</span>
|
</ul>
|
||||||
) : (
|
)}
|
||||||
<ul className="leading-normal">
|
</div>
|
||||||
{node.customRouting.subnetWaitingRoutes.map((route) => (
|
<Button
|
||||||
<li key={route}>{route}</li>
|
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
|
||||||
))}
|
onPress={() => setShowRouting(true)}
|
||||||
</ul>
|
>
|
||||||
)}
|
Edit
|
||||||
</div>
|
</Button>
|
||||||
<Button
|
</div>
|
||||||
className={cn(
|
<div>
|
||||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||||
'text-blue-500 dark:text-blue-400',
|
Exit Node
|
||||||
)}
|
<Tooltip>
|
||||||
onPress={() => setShowRouting(true)}
|
<Info className="h-3.5 w-3.5" />
|
||||||
>
|
<Tooltip.Body>
|
||||||
Edit
|
Whether this machine can act as an exit node for your tailnet.
|
||||||
</Button>
|
</Tooltip.Body>
|
||||||
</div>
|
</Tooltip>
|
||||||
<div>
|
</span>
|
||||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
<div className="mt-1">
|
||||||
Exit Node
|
{node.customRouting.exitRoutes.length === 0 ? (
|
||||||
<Tooltip>
|
<span className="opacity-50">—</span>
|
||||||
<Info className="w-3.5 h-3.5" />
|
) : node.customRouting.exitApproved ? (
|
||||||
<Tooltip.Body>
|
<span className="flex items-center gap-x-1">
|
||||||
Whether this machine can act as an exit node for your tailnet.
|
<CheckCircle className="h-3.5 w-3.5 text-green-700" />
|
||||||
</Tooltip.Body>
|
Allowed
|
||||||
</Tooltip>
|
</span>
|
||||||
</span>
|
) : (
|
||||||
<div className="mt-1">
|
<span className="flex items-center gap-x-1">
|
||||||
{node.customRouting.exitRoutes.length === 0 ? (
|
<CircleSlash className="h-3.5 w-3.5 text-red-700" />
|
||||||
<span className="opacity-50">—</span>
|
Awaiting Approval
|
||||||
) : node.customRouting.exitApproved ? (
|
</span>
|
||||||
<span className="flex items-center gap-x-1">
|
)}
|
||||||
<CheckCircle className="w-3.5 h-3.5 text-green-700" />
|
</div>
|
||||||
Allowed
|
<Button
|
||||||
</span>
|
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
|
||||||
) : (
|
onPress={() => setShowRouting(true)}
|
||||||
<span className="flex items-center gap-x-1">
|
>
|
||||||
<CircleSlash className="w-3.5 h-3.5 text-red-700" />
|
Edit
|
||||||
Awaiting Approval
|
</Button>
|
||||||
</span>
|
</div>
|
||||||
)}
|
</Card>
|
||||||
</div>
|
<h2 className="text-xl font-medium">Machine Details</h2>
|
||||||
<Button
|
<p className="mb-4">
|
||||||
className={cn(
|
Information about this machine’s network. Used to debug connection issues.
|
||||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
</p>
|
||||||
'text-blue-500 dark:text-blue-400',
|
<Card
|
||||||
)}
|
className="grid w-full max-w-full grid-cols-1 gap-y-2 sm:gap-x-12 lg:grid-cols-2"
|
||||||
onPress={() => setShowRouting(true)}
|
variant="flat"
|
||||||
>
|
>
|
||||||
Edit
|
<div className="flex flex-col gap-1">
|
||||||
</Button>
|
<Attribute
|
||||||
</div>
|
name="Creator"
|
||||||
</Card>
|
value={node.user ? getUserDisplayName(node.user) : "Tag-owned"}
|
||||||
<h2 className="text-xl font-medium">Machine Details</h2>
|
/>
|
||||||
<p className="mb-4">
|
<Attribute name="Machine name" value={node.givenName} />
|
||||||
Information about this machine’s network. Used to debug connection
|
<Attribute
|
||||||
issues.
|
name="OS hostname"
|
||||||
</p>
|
tooltip="OS hostname is published by the machine’s operating system and is used as the default name for the machine."
|
||||||
<Card
|
value={node.name}
|
||||||
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12"
|
/>
|
||||||
variant="flat"
|
{stats ? (
|
||||||
>
|
<>
|
||||||
<div className="flex flex-col gap-1">
|
<Attribute name="OS" value={getOSInfo(stats)} />
|
||||||
<Attribute
|
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
|
||||||
name="Creator"
|
</>
|
||||||
value={
|
) : undefined}
|
||||||
node.user.name ||
|
<Attribute
|
||||||
node.user.displayName ||
|
name="ID"
|
||||||
node.user.email ||
|
tooltip="ID for this machine. Used in the Headscale API."
|
||||||
node.user.id
|
value={node.id}
|
||||||
}
|
/>
|
||||||
/>
|
<Attribute
|
||||||
<Attribute name="Machine name" value={node.givenName} />
|
isCopyable
|
||||||
<Attribute
|
name="Node key"
|
||||||
name="OS hostname"
|
tooltip="Public key which uniquely identifies this machine."
|
||||||
tooltip="OS hostname is published by the machine’s operating system and is used as the default name for the machine."
|
value={node.nodeKey}
|
||||||
value={node.name}
|
/>
|
||||||
/>
|
<Attribute name="Created" value={new Date(node.createdAt).toLocaleString()} />
|
||||||
{stats ? (
|
<Attribute
|
||||||
<>
|
name="Last Seen"
|
||||||
<Attribute name="OS" value={getOSInfo(stats)} />
|
value={node.online ? "Connected" : new Date(node.lastSeen).toLocaleString()}
|
||||||
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
|
/>
|
||||||
</>
|
<Attribute
|
||||||
) : undefined}
|
name="Key expiry"
|
||||||
<Attribute
|
value={node.expiry !== null ? new Date(node.expiry).toLocaleString() : "Never"}
|
||||||
name="ID"
|
/>
|
||||||
tooltip="ID for this machine. Used in the Headscale API."
|
{magic ? (
|
||||||
value={node.id}
|
<Attribute isCopyable name="Domain" value={`${node.givenName}.${magic}`} />
|
||||||
/>
|
) : undefined}
|
||||||
<Attribute
|
</div>
|
||||||
isCopyable
|
<div className="flex flex-col gap-1">
|
||||||
name="Node key"
|
<p className="text-sm font-semibold uppercase opacity-75">Addresses</p>
|
||||||
tooltip="Public key which uniquely identifies this machine."
|
<Attribute
|
||||||
value={node.nodeKey}
|
isCopyable
|
||||||
/>
|
name="Tailscale IPv4"
|
||||||
<Attribute
|
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
||||||
name="Created"
|
value={getIpv4Address(node.ipAddresses)}
|
||||||
value={new Date(node.createdAt).toLocaleString()}
|
/>
|
||||||
/>
|
<Attribute
|
||||||
<Attribute
|
isCopyable
|
||||||
name="Last Seen"
|
name="Tailscale IPv6"
|
||||||
value={
|
tooltip="This machine’s IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
|
||||||
node.online
|
value={getIpv6Address(node.ipAddresses)}
|
||||||
? 'Connected'
|
/>
|
||||||
: new Date(node.lastSeen).toLocaleString()
|
<Attribute
|
||||||
}
|
isCopyable
|
||||||
/>
|
name="Short domain"
|
||||||
<Attribute
|
tooltip="Users of your tailnet can use this DNS short name to access this machine."
|
||||||
name="Key expiry"
|
value={node.givenName}
|
||||||
value={
|
/>
|
||||||
node.expiry !== null
|
{magic ? (
|
||||||
? new Date(node.expiry).toLocaleString()
|
<Attribute
|
||||||
: 'Never'
|
isCopyable
|
||||||
}
|
name="Full domain"
|
||||||
/>
|
tooltip="Users of your tailnet can use this DNS name to access this machine."
|
||||||
{magic ? (
|
value={`${node.givenName}.${magic}`}
|
||||||
<Attribute
|
/>
|
||||||
isCopyable
|
) : undefined}
|
||||||
name="Domain"
|
{stats ? (
|
||||||
value={`${node.givenName}.${magic}`}
|
<>
|
||||||
/>
|
<p className="mt-4 text-sm font-semibold uppercase opacity-75">Client Connectivity</p>
|
||||||
) : undefined}
|
<Attribute
|
||||||
</div>
|
name="Varies"
|
||||||
<div className="flex flex-col gap-1">
|
tooltip="Whether the machine is behind a difficult NAT that varies the machine’s IP address depending on the destination."
|
||||||
<p className="uppercase text-sm font-semibold opacity-75">
|
value={stats.NetInfo?.MappingVariesByDestIP ? "Yes" : "No"}
|
||||||
Addresses
|
/>
|
||||||
</p>
|
<Attribute
|
||||||
<Attribute
|
name="Hairpinning"
|
||||||
isCopyable
|
tooltip="Whether the machine needs to traverse NATs with hairpinning."
|
||||||
name="Tailscale IPv4"
|
value={stats.NetInfo?.HairPinning ? "Yes" : "No"}
|
||||||
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
/>
|
||||||
value={getIpv4Address(node.ipAddresses)}
|
<Attribute name="IPv6" value={stats.NetInfo?.WorkingIPv6 ? "Yes" : "No"} />
|
||||||
/>
|
<Attribute name="UDP" value={stats.NetInfo?.WorkingUDP ? "Yes" : "No"} />
|
||||||
<Attribute
|
<Attribute name="UPnP" value={stats.NetInfo?.UPnP ? "Yes" : "No"} />
|
||||||
isCopyable
|
<Attribute name="PCP" value={stats.NetInfo?.PCP ? "Yes" : "No"} />
|
||||||
name="Tailscale IPv6"
|
<Attribute name="NAT-PMP" value={stats.NetInfo?.PMP ? "Yes" : "No"} />
|
||||||
tooltip="This machine’s IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
|
</>
|
||||||
value={getIpv6Address(node.ipAddresses)}
|
) : undefined}
|
||||||
/>
|
</div>
|
||||||
<Attribute
|
</Card>
|
||||||
isCopyable
|
</div>
|
||||||
name="Short domain"
|
);
|
||||||
tooltip="Users of your tailnet can use this DNS short name to access this machine."
|
|
||||||
value={node.givenName}
|
|
||||||
/>
|
|
||||||
{magic ? (
|
|
||||||
<Attribute
|
|
||||||
isCopyable
|
|
||||||
name="Full domain"
|
|
||||||
tooltip="Users of your tailnet can use this DNS name to access this machine."
|
|
||||||
value={`${node.givenName}.${magic}`}
|
|
||||||
/>
|
|
||||||
) : undefined}
|
|
||||||
{stats ? (
|
|
||||||
<>
|
|
||||||
<p className="uppercase text-sm font-semibold opacity-75 mt-4">
|
|
||||||
Client Connectivity
|
|
||||||
</p>
|
|
||||||
<Attribute
|
|
||||||
name="Varies"
|
|
||||||
tooltip="Whether the machine is behind a difficult NAT that varies the machine’s IP address depending on the destination."
|
|
||||||
value={stats.NetInfo?.MappingVariesByDestIP ? 'Yes' : 'No'}
|
|
||||||
/>
|
|
||||||
<Attribute
|
|
||||||
name="Hairpinning"
|
|
||||||
tooltip="Whether the machine needs to traverse NATs with hairpinning."
|
|
||||||
value={stats.NetInfo?.HairPinning ? 'Yes' : 'No'}
|
|
||||||
/>
|
|
||||||
<Attribute
|
|
||||||
name="IPv6"
|
|
||||||
value={stats.NetInfo?.WorkingIPv6 ? 'Yes' : 'No'}
|
|
||||||
/>
|
|
||||||
<Attribute
|
|
||||||
name="UDP"
|
|
||||||
value={stats.NetInfo?.WorkingUDP ? 'Yes' : 'No'}
|
|
||||||
/>
|
|
||||||
<Attribute
|
|
||||||
name="UPnP"
|
|
||||||
value={stats.NetInfo?.UPnP ? 'Yes' : 'No'}
|
|
||||||
/>
|
|
||||||
<Attribute name="PCP" value={stats.NetInfo?.PCP ? 'Yes' : 'No'} />
|
|
||||||
<Attribute
|
|
||||||
name="NAT-PMP"
|
|
||||||
value={stats.NetInfo?.PMP ? 'Yes' : 'No'}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : undefined}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIpv4Address(addresses: string[]) {
|
function getIpv4Address(addresses: string[]) {
|
||||||
for (const address of addresses) {
|
for (const address of addresses) {
|
||||||
if (address.startsWith('100.')) {
|
if (address.startsWith("100.")) {
|
||||||
// Return the first CGNAT address
|
// Return the first CGNAT address
|
||||||
return address;
|
return address;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return '—';
|
return "—";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getIpv6Address(addresses: string[]) {
|
function getIpv6Address(addresses: string[]) {
|
||||||
for (const address of addresses) {
|
for (const address of addresses) {
|
||||||
if (address.startsWith('fd')) {
|
if (address.startsWith("fd")) {
|
||||||
// Return the first IPv6 address
|
// Return the first IPv6 address
|
||||||
return address;
|
return address;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return '—';
|
return "—";
|
||||||
}
|
}
|
||||||
|
|||||||
+362
-144
@@ -1,157 +1,375 @@
|
|||||||
import { Info } from 'lucide-react';
|
import { ChevronDown, ChevronUp, Info, X } from "lucide-react";
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
import { useMemo, useState } from "react";
|
||||||
import { useLoaderData } from 'react-router';
|
|
||||||
import Code from '~/components/Code';
|
|
||||||
import Link from '~/components/Link';
|
|
||||||
import Tooltip from '~/components/Tooltip';
|
|
||||||
import type { LoadContext } from '~/server';
|
|
||||||
import { Capabilities } from '~/server/web/roles';
|
|
||||||
import type { Machine, User } from '~/types';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
import { mapNodes } from '~/utils/node-info';
|
|
||||||
import MachineRow from './components/machine-row';
|
|
||||||
import NewMachine from './dialogs/new';
|
|
||||||
import { machineAction } from './machine-actions';
|
|
||||||
|
|
||||||
export async function loader({
|
import Code from "~/components/Code";
|
||||||
request,
|
import Input from "~/components/Input";
|
||||||
context,
|
import Link from "~/components/Link";
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
import Tooltip from "~/components/Tooltip";
|
||||||
const session = await context.sessions.auth(request);
|
import { Capabilities } from "~/server/web/roles";
|
||||||
const user = session.user;
|
import cn from "~/utils/cn";
|
||||||
if (!user) {
|
import { mapNodes, sortNodeTags } from "~/utils/node-info";
|
||||||
throw new Error('Missing user session. Please log in again.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const check = await context.sessions.check(
|
import type { Route } from "./+types/overview";
|
||||||
request,
|
import MachineRow from "./components/machine-row";
|
||||||
Capabilities.read_machines,
|
import NewMachine from "./dialogs/new";
|
||||||
);
|
import { machineAction } from "./machine-actions";
|
||||||
|
|
||||||
if (!check) {
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
// Not authorized to view this page
|
const principal = await context.auth.require(request);
|
||||||
throw new Error(
|
|
||||||
'You do not have permission to view this page. Please contact your administrator.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const writablePermission = await context.sessions.check(
|
if (!context.auth.can(principal, Capabilities.read_machines)) {
|
||||||
request,
|
throw new Error(
|
||||||
Capabilities.write_machines,
|
"You do not have permission to view this page. Please contact your administrator.",
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const [{ nodes }, { users }] = await Promise.all([
|
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
|
||||||
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
|
|
||||||
context.client.get<{ users: User[] }>('v1/user', session.api_key),
|
|
||||||
]);
|
|
||||||
|
|
||||||
let magic: string | undefined;
|
const api = context.hsApi.getRuntimeClient(
|
||||||
if (context.hs.readable()) {
|
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
|
||||||
if (context.hs.c?.dns.magic_dns) {
|
);
|
||||||
magic = context.hs.c.dns.base_domain;
|
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
|
let magic: string | undefined;
|
||||||
const populatedNodes = mapNodes(nodes, stats);
|
if (context.hs.readable()) {
|
||||||
|
if (context.hs.c?.dns.magic_dns) {
|
||||||
|
magic = context.hs.c.dns.base_domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
|
||||||
populatedNodes,
|
const populatedNodes = mapNodes(nodes, stats);
|
||||||
nodes,
|
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
|
||||||
users,
|
|
||||||
magic,
|
return {
|
||||||
server: context.config.headscale.url,
|
populatedNodes,
|
||||||
publicServer: context.config.headscale.public_url,
|
nodes,
|
||||||
agent: context.agents?.agentID(),
|
users,
|
||||||
writable: writablePermission,
|
magic,
|
||||||
preAuth: await context.sessions.check(
|
server: context.config.headscale.url,
|
||||||
request,
|
publicServer: context.config.headscale.public_url,
|
||||||
Capabilities.generate_authkeys,
|
agent: context.agents?.agentID(),
|
||||||
),
|
writable: writablePermission,
|
||||||
subject: user.subject,
|
preAuth: context.auth.can(principal, Capabilities.generate_authkeys),
|
||||||
};
|
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
|
||||||
|
supportsNodeOwnerChange: supportsNodeOwnerChange,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action(request: ActionFunctionArgs) {
|
export const action = machineAction;
|
||||||
return machineAction(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
type SortField = "name" | "ip" | "version" | "lastSeen";
|
||||||
const data = useLoaderData<typeof loader>();
|
|
||||||
|
|
||||||
return (
|
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||||
<>
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
<div className="flex justify-between items-center mb-6">
|
const [sortField, setSortField] = useState<SortField>("name");
|
||||||
<div className="flex flex-col w-2/3">
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||||
<h1 className="text-2xl font-medium mb-2">Machines</h1>
|
|
||||||
<p>
|
const filteredAndSortedNodes = useMemo(() => {
|
||||||
Manage the devices connected to your Tailnet.{' '}
|
const query = searchQuery.toLowerCase().trim();
|
||||||
<Link
|
|
||||||
name="Tailscale Manage Devices Documentation"
|
let nodes = loaderData.populatedNodes.filter((node) => {
|
||||||
to="https://tailscale.com/kb/1372/manage-devices"
|
if (!query) return true;
|
||||||
>
|
if (node.givenName.toLowerCase().includes(query)) return true;
|
||||||
Learn more
|
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) return true;
|
||||||
</Link>
|
return false;
|
||||||
</p>
|
});
|
||||||
</div>
|
|
||||||
<NewMachine
|
nodes = [...nodes].sort((a, b) => {
|
||||||
disabledKeys={data.preAuth ? [] : ['pre-auth']}
|
let comparison = 0;
|
||||||
isDisabled={!data.writable}
|
|
||||||
server={data.publicServer ?? data.server}
|
switch (sortField) {
|
||||||
users={data.users}
|
case "name":
|
||||||
/>
|
comparison = a.givenName.localeCompare(b.givenName);
|
||||||
</div>
|
break;
|
||||||
<table className="table-auto w-full rounded-lg">
|
case "ip": {
|
||||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
const getIPv4 = (addresses: string[]) =>
|
||||||
<tr className="text-left px-0.5">
|
addresses.find((ip) => !ip.includes(":")) || addresses[0] || "";
|
||||||
<th className="uppercase text-xs font-bold pb-2">Name</th>
|
const ipA = getIPv4(a.ipAddresses);
|
||||||
<th className="pb-2 w-1/4">
|
const ipB = getIPv4(b.ipAddresses);
|
||||||
<div className="flex items-center gap-x-1">
|
|
||||||
<p className="uppercase text-xs font-bold">Addresses</p>
|
if (!ipA.includes(":") && !ipB.includes(":")) {
|
||||||
{data.magic ? (
|
const octetsA = ipA.split(".").map(Number);
|
||||||
<Tooltip>
|
const octetsB = ipB.split(".").map(Number);
|
||||||
<Info className="w-4 h-4" />
|
for (let i = 0; i < 4; i++) {
|
||||||
<Tooltip.Body className="font-normal">
|
if (octetsA[i] !== octetsB[i]) {
|
||||||
Since MagicDNS is enabled, you can access devices based on
|
comparison = octetsA[i] - octetsB[i];
|
||||||
their name and also at{' '}
|
break;
|
||||||
<Code>
|
}
|
||||||
[name].
|
}
|
||||||
{data.magic}
|
} else {
|
||||||
</Code>
|
comparison = ipA.localeCompare(ipB);
|
||||||
</Tooltip.Body>
|
}
|
||||||
</Tooltip>
|
break;
|
||||||
) : undefined}
|
}
|
||||||
</div>
|
case "version": {
|
||||||
</th>
|
const versionA = a.hostInfo?.IPNVersion?.split("-")[0] || "0";
|
||||||
{/* We only want to show the version column if there are agents */}
|
const versionB = b.hostInfo?.IPNVersion?.split("-")[0] || "0";
|
||||||
{data.agent !== undefined ? (
|
const partsA = versionA.split(".").map(Number);
|
||||||
<th className="uppercase text-xs font-bold pb-2">Version</th>
|
const partsB = versionB.split(".").map(Number);
|
||||||
) : undefined}
|
const maxLen = Math.max(partsA.length, partsB.length);
|
||||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
|
||||||
</tr>
|
for (let i = 0; i < maxLen; i++) {
|
||||||
</thead>
|
const segA = partsA[i] || 0;
|
||||||
<tbody
|
const segB = partsB[i] || 0;
|
||||||
className={cn(
|
if (segA !== segB) {
|
||||||
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
|
comparison = segA - segB;
|
||||||
'border-t border-headplane-100 dark:border-headplane-800',
|
break;
|
||||||
)}
|
}
|
||||||
>
|
}
|
||||||
{data.populatedNodes.map((machine) => (
|
break;
|
||||||
<MachineRow
|
}
|
||||||
isAgent={data.agent ? data.agent === machine.nodeKey : undefined}
|
case "lastSeen":
|
||||||
isDisabled={
|
if (a.online !== b.online) {
|
||||||
data.writable
|
comparison = a.online ? 1 : -1;
|
||||||
? false // If the user has write permissions, they can edit all machines
|
break;
|
||||||
: machine.user.providerId?.split('/').pop() !== data.subject
|
}
|
||||||
}
|
comparison = new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime();
|
||||||
key={machine.id}
|
break;
|
||||||
magic={data.magic}
|
}
|
||||||
node={machine}
|
|
||||||
users={data.users}
|
return sortDirection === "asc" ? comparison : -comparison;
|
||||||
/>
|
});
|
||||||
))}
|
|
||||||
</tbody>
|
return nodes;
|
||||||
</table>
|
}, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]);
|
||||||
</>
|
|
||||||
);
|
const handleSort = (field: SortField) => {
|
||||||
|
if (sortField === field) {
|
||||||
|
setSortDirection((prev) => (prev === "asc" ? "desc" : "asc"));
|
||||||
|
} else {
|
||||||
|
setSortField(field);
|
||||||
|
setSortDirection("asc");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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,118 +1,123 @@
|
|||||||
import { ActionFunctionArgs, data } from 'react-router';
|
import { data } from "react-router";
|
||||||
import { LoadContext } from '~/server';
|
|
||||||
import { Capabilities } from '~/server/web/roles';
|
|
||||||
import { PreAuthKey } from '~/types';
|
|
||||||
|
|
||||||
export async function authKeysAction({
|
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||||
request,
|
import { Capabilities } from "~/server/web/roles";
|
||||||
context,
|
|
||||||
}: ActionFunctionArgs<LoadContext>) {
|
|
||||||
const session = await context.sessions.auth(request);
|
|
||||||
const check = await context.sessions.check(
|
|
||||||
request,
|
|
||||||
Capabilities.generate_authkeys,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!check) {
|
import type { Route } from "./+types/overview";
|
||||||
throw data('You do not have permission to manage pre-auth keys', {
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = await request.formData();
|
export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||||
const apiKey = session.api_key;
|
const principal = await context.auth.require(request);
|
||||||
const action = formData.get('action_id')?.toString();
|
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||||
if (!action) {
|
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||||
throw data('Missing `action_id` in the form data.', {
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (action) {
|
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
|
||||||
case 'add_preauthkey':
|
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
|
||||||
return await addPreAuthKey(formData, apiKey, context);
|
|
||||||
case 'expire_preauthkey':
|
if (!canGenerateAny && !canGenerateOwn) {
|
||||||
return await expirePreAuthKey(formData, apiKey, context);
|
throw data("You do not have permission to manage pre-auth keys", {
|
||||||
default:
|
status: 403,
|
||||||
return data('Invalid action', {
|
});
|
||||||
status: 400,
|
}
|
||||||
});
|
|
||||||
}
|
async function checkSelfServiceOwnership(userId: string) {
|
||||||
}
|
if (canGenerateAny || !canGenerateOwn) return;
|
||||||
|
const [targetUser] = await api.getUsers(userId);
|
||||||
async function addPreAuthKey(
|
if (!targetUser) {
|
||||||
formData: FormData,
|
throw data("User not found.", { status: 404 });
|
||||||
apiKey: string,
|
}
|
||||||
context: LoadContext,
|
const targetSubject = getOidcSubject(targetUser);
|
||||||
) {
|
if (principal.kind !== "oidc" || targetSubject !== principal.user.subject) {
|
||||||
const user = formData.get('user_id')?.toString();
|
throw data("You do not have permission to manage this user's pre-auth keys", {
|
||||||
if (!user) {
|
status: 403,
|
||||||
return data('Missing `user_id` in the form data.', {
|
});
|
||||||
status: 400,
|
}
|
||||||
});
|
}
|
||||||
}
|
|
||||||
|
const formData = await request.formData();
|
||||||
const expiry = formData.get('expiry')?.toString();
|
const action = formData.get("action_id")?.toString();
|
||||||
if (!expiry) {
|
if (!action) {
|
||||||
return data('Missing `expiry` in the form data.', {
|
throw data("Missing `action_id` in the form data.", {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const reusable = formData.get('reusable')?.toString();
|
switch (action) {
|
||||||
if (!reusable) {
|
case "add_preauthkey": {
|
||||||
return data('Missing `reusable` in the form data.', {
|
const user = formData.get("user_id")?.toString() || null;
|
||||||
status: 400,
|
const aclTagsRaw = formData.get("acl_tags")?.toString() || "";
|
||||||
});
|
const aclTags = aclTagsRaw
|
||||||
}
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
const ephemeral = formData.get('ephemeral')?.toString();
|
.filter((t) => t.length > 0);
|
||||||
if (!ephemeral) {
|
|
||||||
return data('Missing `ephemeral` in the form data.', {
|
if (!user && aclTags.length === 0) {
|
||||||
status: 400,
|
return data("Must specify either a user or ACL tags.", {
|
||||||
});
|
status: 400,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
// Extract the first "word" from expiry which is the day number
|
|
||||||
// Calculate the date X days from now using the day number
|
if (user) {
|
||||||
const day = Number(expiry.toString().split(' ')[0]);
|
await checkSelfServiceOwnership(user);
|
||||||
const date = new Date();
|
}
|
||||||
date.setDate(date.getDate() + day);
|
|
||||||
|
const expiry = formData.get("expiry")?.toString();
|
||||||
await context.client.post<{ preAuthKey: PreAuthKey }>(
|
if (!expiry) {
|
||||||
'v1/preauthkey',
|
return data("Missing `expiry` in the form data.", {
|
||||||
apiKey,
|
status: 400,
|
||||||
{
|
});
|
||||||
user,
|
}
|
||||||
ephemeral: ephemeral === 'on',
|
|
||||||
reusable: reusable === 'on',
|
const reusable = formData.get("reusable")?.toString();
|
||||||
expiration: date.toISOString(),
|
if (!reusable) {
|
||||||
aclTags: [], // TODO
|
return data("Missing `reusable` in the form data.", {
|
||||||
},
|
status: 400,
|
||||||
);
|
});
|
||||||
|
}
|
||||||
return data('Pre-auth key created');
|
|
||||||
}
|
const ephemeral = formData.get("ephemeral")?.toString();
|
||||||
|
if (!ephemeral) {
|
||||||
async function expirePreAuthKey(
|
return data("Missing `ephemeral` in the form data.", {
|
||||||
formData: FormData,
|
status: 400,
|
||||||
apiKey: string,
|
});
|
||||||
context: LoadContext,
|
}
|
||||||
) {
|
|
||||||
const key = formData.get('key')?.toString();
|
const day = Number(expiry.toString().split(" ")[0]);
|
||||||
if (!key) {
|
const date = new Date();
|
||||||
return data('Missing `key` in the form data.', {
|
date.setDate(date.getDate() + day);
|
||||||
status: 400,
|
|
||||||
});
|
const key = await api.createPreAuthKey(
|
||||||
}
|
user,
|
||||||
|
ephemeral === "on",
|
||||||
const user = formData.get('user_id')?.toString();
|
reusable === "on",
|
||||||
if (!user) {
|
date,
|
||||||
return data('Missing `user_id` in the form data.', {
|
aclTags.length > 0 ? aclTags : null,
|
||||||
status: 400,
|
);
|
||||||
});
|
|
||||||
}
|
return data({ success: true as const, key: key.key });
|
||||||
|
}
|
||||||
await context.client.post('v1/preauthkey/expire', apiKey, { user, key });
|
case "expire_preauthkey": {
|
||||||
return data('Pre-auth key expired');
|
const key = formData.get("key")?.toString();
|
||||||
|
if (!key) {
|
||||||
|
return data("Missing `key` in the form data.", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = formData.get("user_id")?.toString();
|
||||||
|
if (!user) {
|
||||||
|
return data("Missing `user_id` in the form data.", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkSelfServiceOwnership(user);
|
||||||
|
|
||||||
|
await api.expirePreAuthKey(user, key);
|
||||||
|
return data("Pre-auth key expired");
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return data("Invalid action", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,35 @@
|
|||||||
import Attribute from '~/components/Attribute';
|
import Attribute from "~/components/Attribute";
|
||||||
import Button from '~/components/Button';
|
import type { PreAuthKey, User } from "~/types";
|
||||||
import Code from '~/components/Code';
|
import { getUserDisplayName } from "~/utils/user";
|
||||||
import type { PreAuthKey, User } from '~/types';
|
|
||||||
import toast from '~/utils/toast';
|
import ExpireAuthKey from "./dialogs/expire-auth-key";
|
||||||
import ExpireAuthKey from './dialogs/expire-auth-key';
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
authKey: PreAuthKey;
|
authKey: PreAuthKey;
|
||||||
user: User;
|
user: User | null;
|
||||||
url: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AuthKeyRow({ authKey, user, url }: Props) {
|
export default function AuthKeyRow({ authKey, user }: Props) {
|
||||||
const createdAt = new Date(authKey.createdAt).toLocaleString();
|
const createdAt = new Date(authKey.createdAt).toLocaleString();
|
||||||
const expiration = new Date(authKey.expiration).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 (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<Attribute isCopyable name="Key" value={authKey.key} />
|
<Attribute name="Key" value={authKey.key} />
|
||||||
<Attribute
|
<Attribute name="User" value={userDisplay} />
|
||||||
isCopyable
|
<Attribute name="Reusable" value={authKey.reusable ? "Yes" : "No"} />
|
||||||
name="User"
|
<Attribute name="Ephemeral" value={authKey.ephemeral ? "Yes" : "No"} />
|
||||||
value={user.name || user.displayName || user.email || user.id}
|
<Attribute name="Used" value={authKey.used ? "Yes" : "No"} />
|
||||||
/>
|
<Attribute name="Created" value={createdAt} />
|
||||||
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
|
<Attribute name="Expiration" value={expiration} />
|
||||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
|
{!isExpired && user && (
|
||||||
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
|
<div className="mt-2" suppressHydrationWarning>
|
||||||
<Attribute name="Created" value={createdAt} />
|
<ExpireAuthKey authKey={authKey} user={user} />
|
||||||
<Attribute name="Expiration" value={expiration} />
|
</div>
|
||||||
<p className="mb-1 mt-4">
|
)}
|
||||||
To use this key, run the following command on your device:
|
</div>
|
||||||
</p>
|
);
|
||||||
<Code className="text-sm">
|
|
||||||
tailscale up --login-server={url} --authkey {authKey.key}
|
|
||||||
</Code>
|
|
||||||
<div className="flex gap-4 items-center" suppressHydrationWarning>
|
|
||||||
{(authKey.used && !authKey.reusable) ||
|
|
||||||
new Date(authKey.expiration) < new Date() ? undefined : (
|
|
||||||
<ExpireAuthKey authKey={authKey} user={user} />
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
className="my-4"
|
|
||||||
onPress={async () => {
|
|
||||||
await navigator.clipboard.writeText(
|
|
||||||
`tailscale up --login-server=${url} --authkey ${authKey.key}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
toast('Copied command to clipboard');
|
|
||||||
}}
|
|
||||||
variant="light"
|
|
||||||
>
|
|
||||||
Copy Tailscale Command
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,100 +1,224 @@
|
|||||||
import { Key, useState } from 'react';
|
import { Key, useEffect, useRef, useState } from "react";
|
||||||
import { useFetcher } from 'react-router';
|
import { useFetcher } from "react-router";
|
||||||
import Dialog from '~/components/Dialog';
|
|
||||||
import Link from '~/components/Link';
|
import Button from "~/components/Button";
|
||||||
import NumberInput from '~/components/NumberInput';
|
import Code from "~/components/Code";
|
||||||
import Select from '~/components/Select';
|
import Dialog from "~/components/Dialog";
|
||||||
import Switch from '~/components/Switch';
|
import Input from "~/components/Input";
|
||||||
import type { User } from '~/types';
|
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 {
|
interface AddAuthKeyProps {
|
||||||
users: User[];
|
users: User[];
|
||||||
|
url: string;
|
||||||
|
selfServiceOnly: boolean;
|
||||||
|
currentSubject?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Tags
|
function findCurrentUser(users: User[], subject: string | undefined): User | undefined {
|
||||||
export default function AddAuthKey(data: AddAuthKeyProps) {
|
if (!subject) return undefined;
|
||||||
const [reusable, setReusable] = useState(false);
|
return users.find((u) => {
|
||||||
const [ephemeral, setEphemeral] = useState(false);
|
if (u.provider !== "oidc" || !u.providerId) return false;
|
||||||
const [userId, setUserId] = useState<Key | null>(data.users[0]?.id);
|
return u.providerId.split("/").pop() === subject;
|
||||||
|
});
|
||||||
return (
|
}
|
||||||
<Dialog>
|
|
||||||
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
|
export default function AddAuthKey({
|
||||||
<Dialog.Panel>
|
users,
|
||||||
<Dialog.Title>Generate auth key</Dialog.Title>
|
url,
|
||||||
<input type="hidden" name="action_id" value="add_preauthkey" />
|
selfServiceOnly,
|
||||||
<input type="hidden" name="user_id" value={userId?.toString()} />
|
currentSubject,
|
||||||
<Select
|
}: AddAuthKeyProps) {
|
||||||
isRequired
|
const fetcher = useFetcher();
|
||||||
label="User"
|
const submittingRef = useRef(false);
|
||||||
name="user"
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
placeholder="Select a user"
|
const [reusable, setReusable] = useState(false);
|
||||||
description="This is the user machines will belong to when they authenticate."
|
const [ephemeral, setEphemeral] = useState(false);
|
||||||
className="mb-2"
|
const [tagOnly, setTagOnly] = useState(false);
|
||||||
onSelectionChange={(value) => {
|
const currentUser = selfServiceOnly ? findCurrentUser(users, currentSubject) : null;
|
||||||
setUserId(value);
|
const availableUsers = selfServiceOnly && currentUser ? [currentUser] : users;
|
||||||
}}
|
const [userId, setUserId] = useState<Key | null>(availableUsers[0]?.id);
|
||||||
>
|
const [tags, setTags] = useState("");
|
||||||
{data.users.map((user) => (
|
|
||||||
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
const createdKey = fetcher.data?.success ? fetcher.data.key : null;
|
||||||
))}
|
|
||||||
</Select>
|
useEffect(() => {
|
||||||
<NumberInput
|
if (fetcher.state === "idle" && fetcher.data) {
|
||||||
isRequired
|
submittingRef.current = false;
|
||||||
name="expiry"
|
}
|
||||||
label="Key Expiration"
|
}, [fetcher.data, fetcher.state]);
|
||||||
description="Set this key to expire after a certain number of days."
|
|
||||||
minValue={1}
|
useEffect(() => {
|
||||||
maxValue={365_000} // 1000 years
|
if (!isOpen) {
|
||||||
defaultValue={90}
|
setReusable(false);
|
||||||
formatOptions={{
|
setEphemeral(false);
|
||||||
style: 'unit',
|
setTagOnly(false);
|
||||||
unit: 'day',
|
setUserId(availableUsers[0]?.id);
|
||||||
unitDisplay: 'short',
|
setTags("");
|
||||||
}}
|
fetcher.data = undefined;
|
||||||
/>
|
}
|
||||||
<div className="flex justify-between items-center gap-2 mt-6">
|
}, [isOpen]);
|
||||||
<div>
|
|
||||||
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
|
const parsedTags = tags
|
||||||
<Dialog.Text className="text-sm">
|
.split(",")
|
||||||
Use this key to authenticate more than one device.
|
.map((t) => t.trim())
|
||||||
</Dialog.Text>
|
.filter((t) => t.length > 0)
|
||||||
</div>
|
.map((t) => (t.startsWith("tag:") ? t : `tag:${t}`));
|
||||||
<Switch
|
|
||||||
label="Reusable"
|
const canSubmit = tagOnly ? parsedTags.length > 0 : userId != null;
|
||||||
name="reusable"
|
|
||||||
defaultSelected={reusable}
|
return (
|
||||||
onChange={() => {
|
<Dialog
|
||||||
setReusable(!reusable);
|
isOpen={isOpen}
|
||||||
}}
|
onOpenChange={(open) => {
|
||||||
/>
|
if (!open && submittingRef.current) return;
|
||||||
</div>
|
setIsOpen(open);
|
||||||
<input type="hidden" name="reusable" value={reusable.toString()} />
|
}}
|
||||||
<div className="flex justify-between items-center gap-2 mt-6">
|
>
|
||||||
<div>
|
<Button className="my-4" onPress={() => setIsOpen(true)}>
|
||||||
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
|
Create pre-auth key
|
||||||
<Dialog.Text className="text-sm">
|
</Button>
|
||||||
Devices authenticated with this key will be automatically removed
|
{createdKey ? (
|
||||||
once they go offline.{' '}
|
<Dialog.Panel variant="unactionable">
|
||||||
<Link
|
<Dialog.Title>Pre-auth key created</Dialog.Title>
|
||||||
to="https://tailscale.com/kb/1111/ephemeral-nodes"
|
<Dialog.Text>
|
||||||
name="Tailscale Ephemeral Nodes Documentation"
|
Copy this key now. You will not be able to see the full key again.
|
||||||
>
|
</Dialog.Text>
|
||||||
Learn more
|
<div className="bg-headplane-100 dark:bg-headplane-800 mt-4 flex items-center gap-2 rounded-lg px-3 py-2">
|
||||||
</Link>
|
<code className="min-w-0 flex-1 truncate font-mono text-sm">{createdKey}</code>
|
||||||
</Dialog.Text>
|
<Button
|
||||||
</div>
|
className="shrink-0"
|
||||||
<Switch
|
onPress={async () => {
|
||||||
label="Ephemeral"
|
await navigator.clipboard.writeText(createdKey);
|
||||||
name="ephemeral"
|
toast("Copied key to clipboard");
|
||||||
defaultSelected={ephemeral}
|
}}
|
||||||
onChange={() => {
|
variant="light"
|
||||||
setEphemeral(!ephemeral);
|
>
|
||||||
}}
|
Copy
|
||||||
/>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<input type="hidden" name="ephemeral" value={ephemeral.toString()} />
|
<Dialog.Text className="mt-4 text-sm">To register a device with this key:</Dialog.Text>
|
||||||
</Dialog.Panel>
|
<Code isCopyable className="mt-1 block text-sm">
|
||||||
</Dialog>
|
{`tailscale up --login-server=${url} --authkey ${createdKey}`}
|
||||||
);
|
</Code>
|
||||||
|
</Dialog.Panel>
|
||||||
|
) : (
|
||||||
|
<Dialog.Panel
|
||||||
|
onSubmit={(event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
submittingRef.current = true;
|
||||||
|
const form = new FormData(event.currentTarget as HTMLFormElement);
|
||||||
|
form.set("action_id", "add_preauthkey");
|
||||||
|
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" || !canSubmit}
|
||||||
|
>
|
||||||
|
<Dialog.Title>Generate auth key</Dialog.Title>
|
||||||
|
|
||||||
|
{!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="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."
|
||||||
|
formatOptions={{
|
||||||
|
style: "unit",
|
||||||
|
unit: "day",
|
||||||
|
unitDisplay: "short",
|
||||||
|
}}
|
||||||
|
isRequired
|
||||||
|
label="Key Expiration"
|
||||||
|
maxValue={365_000}
|
||||||
|
minValue={1}
|
||||||
|
name="expiry"
|
||||||
|
/>
|
||||||
|
<div className="mt-6 flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
Use this key to authenticate more than one device.
|
||||||
|
</Dialog.Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
defaultSelected={reusable}
|
||||||
|
label="Reusable"
|
||||||
|
onChange={() => setReusable(!reusable)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
|
||||||
|
<Dialog.Text className="text-sm">
|
||||||
|
Devices authenticated with this key will be automatically removed once they go
|
||||||
|
offline.{" "}
|
||||||
|
<Link
|
||||||
|
name="Tailscale Ephemeral Nodes Documentation"
|
||||||
|
to="https://tailscale.com/kb/1111/ephemeral-nodes"
|
||||||
|
>
|
||||||
|
Learn more
|
||||||
|
</Link>
|
||||||
|
</Dialog.Text>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
defaultSelected={ephemeral}
|
||||||
|
label="Ephemeral"
|
||||||
|
onChange={() => setEphemeral(!ephemeral)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
)}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
|
|||||||
<Dialog.Button variant="heavy">Expire Key</Dialog.Button>
|
<Dialog.Button variant="heavy">Expire Key</Dialog.Button>
|
||||||
<Dialog.Panel variant="destructive">
|
<Dialog.Panel variant="destructive">
|
||||||
<Dialog.Title>Expire auth key?</Dialog.Title>
|
<Dialog.Title>Expire auth key?</Dialog.Title>
|
||||||
<input type="hidden" name="action_id" value="expire_preauthkey" />
|
<input name="action_id" type="hidden" value="expire_preauthkey" />
|
||||||
<input type="hidden" name="user_id" value={user.id} />
|
<input name="user_id" type="hidden" value={user.id} />
|
||||||
<input type="hidden" name="key" value={authKey.key} />
|
<input name="key" type="hidden" value={authKey.key} />
|
||||||
<Dialog.Text>
|
<Dialog.Text>
|
||||||
Expiring this authentication key will immediately prevent it from
|
Expiring this authentication key will immediately prevent it from
|
||||||
being used to authenticate new devices. This action cannot be undone.
|
being used to authenticate new devices. This action cannot be undone.
|
||||||
|
|||||||
@@ -1,256 +1,284 @@
|
|||||||
import { FileKey2 } from 'lucide-react';
|
import { FileKey2 } from "lucide-react";
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from "react";
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
import { Link as RemixLink } from "react-router";
|
||||||
import { Link as RemixLink, useLoaderData } from 'react-router';
|
|
||||||
import Code from '~/components/Code';
|
|
||||||
import Link from '~/components/Link';
|
|
||||||
import Notice from '~/components/Notice';
|
|
||||||
import Select from '~/components/Select';
|
|
||||||
import TableList from '~/components/TableList';
|
|
||||||
import type { LoadContext } from '~/server';
|
|
||||||
import { Capabilities } from '~/server/web/roles';
|
|
||||||
import type { PreAuthKey, User } from '~/types';
|
|
||||||
import log from '~/utils/log';
|
|
||||||
import { authKeysAction } from './actions';
|
|
||||||
import AuthKeyRow from './auth-key-row';
|
|
||||||
import AddAuthKey from './dialogs/add-auth-key';
|
|
||||||
|
|
||||||
export async function loader({
|
import Code from "~/components/Code";
|
||||||
request,
|
import Link from "~/components/Link";
|
||||||
context,
|
import Notice from "~/components/Notice";
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
import Select from "~/components/Select";
|
||||||
const session = await context.sessions.auth(request);
|
import TableList from "~/components/TableList";
|
||||||
const { users } = await context.client.get<{ users: User[] }>(
|
import { Capabilities } from "~/server/web/roles";
|
||||||
'v1/user',
|
import type { PreAuthKey } from "~/types";
|
||||||
session.api_key,
|
import type { User } from "~/types/User";
|
||||||
);
|
import log from "~/utils/log";
|
||||||
|
import { getUserDisplayName } from "~/utils/user";
|
||||||
|
|
||||||
const preAuthKeys = await Promise.all(
|
import type { Route } from "./+types/overview";
|
||||||
users
|
import { authKeysAction } from "./actions";
|
||||||
.filter((user) => user.name?.length > 0) // Filter out any invalid users
|
import AuthKeyRow from "./auth-key-row";
|
||||||
.map(async (user) => {
|
import AddAuthKey from "./dialogs/add-auth-key";
|
||||||
const qp = new URLSearchParams();
|
|
||||||
qp.set('user', user.id);
|
|
||||||
|
|
||||||
try {
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
const { preAuthKeys } = await context.client.get<{
|
const principal = await context.auth.require(request);
|
||||||
preAuthKeys: PreAuthKey[];
|
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||||
}>(`v1/preauthkey?${qp.toString()}`, session.api_key);
|
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
user,
|
|
||||||
preAuthKeys,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
log.error('api', 'GET /v1/preauthkey for %s: %o', user.name, error);
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
user,
|
|
||||||
error,
|
|
||||||
preAuthKeys: [] as PreAuthKey[],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const keys = preAuthKeys
|
const users = await api.getUsers();
|
||||||
.filter(({ success }) => success)
|
|
||||||
.map(({ user, preAuthKeys }) => ({
|
|
||||||
user,
|
|
||||||
preAuthKeys,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const missing = preAuthKeys
|
let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[];
|
||||||
.filter(({ success }) => !success)
|
let missing: { user: User; error: unknown }[] = [];
|
||||||
.map(({ user, error }) => ({
|
|
||||||
user,
|
|
||||||
error,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return {
|
// Try fetching all keys at once (Headscale 0.28+), fall back to per-user
|
||||||
keys,
|
let allKeys: PreAuthKey[] | null = null;
|
||||||
missing,
|
try {
|
||||||
users,
|
allKeys = await api.getAllPreAuthKeys();
|
||||||
access: await context.sessions.check(
|
} catch {
|
||||||
request,
|
// older versions don't support this endpoint
|
||||||
Capabilities.generate_authkeys,
|
}
|
||||||
),
|
|
||||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
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: canGenerateAny || canGenerateOwn,
|
||||||
|
selfServiceOnly: !canGenerateAny && canGenerateOwn,
|
||||||
|
currentSubject: principal.kind === "oidc" ? principal.user.subject : undefined,
|
||||||
|
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action(request: ActionFunctionArgs<LoadContext>) {
|
export const action = authKeysAction;
|
||||||
return authKeysAction(request);
|
|
||||||
}
|
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
|
||||||
|
export default function Page({
|
||||||
type Status = 'all' | 'active' | 'expired' | 'reusable' | 'ephemeral';
|
loaderData: { keys, missing, users, url, access, selfServiceOnly, currentSubject },
|
||||||
export default function Page() {
|
}: Route.ComponentProps) {
|
||||||
const { keys, missing, users, url, access } = useLoaderData<typeof loader>();
|
const [selectedUser, setSelectedUser] = useState("__headplane_all");
|
||||||
const [selectedUser, setSelectedUser] = useState('__headplane_all');
|
const [status, setStatus] = useState<Status>("active");
|
||||||
const [status, setStatus] = useState<Status>('active');
|
const isDisabled = !access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0;
|
||||||
const isDisabled =
|
|
||||||
!access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0;
|
const filteredKeys = useMemo(() => {
|
||||||
|
const now = new Date();
|
||||||
const filteredKeys = useMemo(() => {
|
return keys
|
||||||
const now = new Date();
|
.filter(({ user }) => {
|
||||||
return keys
|
if (selectedUser === "__headplane_all") {
|
||||||
.filter(({ user }) => {
|
return true;
|
||||||
if (selectedUser === '__headplane_all') {
|
}
|
||||||
return true;
|
|
||||||
}
|
if (selectedUser === "__headplane_tag_only") {
|
||||||
|
return user === null;
|
||||||
return user.id === selectedUser;
|
}
|
||||||
})
|
|
||||||
.flatMap(({ preAuthKeys }) => preAuthKeys)
|
return user?.id === selectedUser;
|
||||||
.filter((key) => {
|
})
|
||||||
if (status === 'all') {
|
.flatMap(({ preAuthKeys }) => preAuthKeys)
|
||||||
return true;
|
.filter((key) => {
|
||||||
}
|
if (status === "all") {
|
||||||
|
return true;
|
||||||
if (status === 'ephemeral') {
|
}
|
||||||
return key.ephemeral;
|
|
||||||
}
|
if (status === "ephemeral") {
|
||||||
|
return key.ephemeral;
|
||||||
if (status === 'reusable') {
|
}
|
||||||
return key.reusable;
|
|
||||||
}
|
if (status === "reusable") {
|
||||||
|
return key.reusable;
|
||||||
const expiry = new Date(key.expiration);
|
}
|
||||||
if (status === 'expired') {
|
|
||||||
// Expired keys are either used or expired
|
const expiry = new Date(key.expiration);
|
||||||
// BUT only used if they are not reusable
|
if (status === "expired") {
|
||||||
if (key.used && !key.reusable) {
|
// Expired keys are either used or expired
|
||||||
return true;
|
// BUT only used if they are not reusable
|
||||||
}
|
if (key.used && !key.reusable) {
|
||||||
|
return true;
|
||||||
return expiry < now;
|
}
|
||||||
}
|
|
||||||
|
return expiry < now;
|
||||||
if (status === 'active') {
|
}
|
||||||
// Active keys are either not expired or reusable
|
|
||||||
if (expiry < now) {
|
if (status === "active") {
|
||||||
return false;
|
// Active keys are either not expired or reusable
|
||||||
}
|
if (expiry < now) {
|
||||||
|
return false;
|
||||||
if (!key.used) {
|
}
|
||||||
return true;
|
|
||||||
}
|
if (!key.used) {
|
||||||
|
return true;
|
||||||
return key.reusable;
|
}
|
||||||
}
|
|
||||||
|
return key.reusable;
|
||||||
return false;
|
}
|
||||||
});
|
|
||||||
}, [keys, selectedUser, status]);
|
return false;
|
||||||
|
});
|
||||||
return (
|
}, [keys, selectedUser, status]);
|
||||||
<div className="flex flex-col md:w-2/3">
|
|
||||||
<p className="mb-8 text-md">
|
return (
|
||||||
<RemixLink className="font-medium" to="/settings">
|
<div className="flex flex-col md:w-2/3">
|
||||||
Settings
|
<p className="text-md mb-8">
|
||||||
</RemixLink>
|
<RemixLink className="font-medium" to="/settings">
|
||||||
<span className="mx-2">/</span> Pre-Auth Keys
|
Settings
|
||||||
</p>
|
</RemixLink>
|
||||||
{!access ? (
|
<span className="mx-2">/</span> Pre-Auth Keys
|
||||||
<Notice title="Pre-auth key permissions restricted" variant="warning">
|
</p>
|
||||||
You do not have the necessary permissions to generate pre-auth keys.
|
{!access ? (
|
||||||
Please contact your administrator to request access or to generate a
|
<Notice title="Pre-auth key permissions restricted" variant="warning">
|
||||||
pre-auth key for you.
|
You do not have the necessary permissions to generate pre-auth keys. Please contact your
|
||||||
</Notice>
|
administrator to request access or to generate a pre-auth key for you.
|
||||||
) : missing.length > 0 ? (
|
</Notice>
|
||||||
<Notice title="Missing authentication keys" variant="error">
|
) : missing.length > 0 ? (
|
||||||
An error occurred while fetching the authentication keys for the
|
<Notice title="Missing authentication keys" variant="error">
|
||||||
following users:{' '}
|
An error occurred while fetching the authentication keys for the following users:{" "}
|
||||||
{missing.map(({ user }, index) => (
|
{missing.map(({ user }, index) => (
|
||||||
<>
|
<>
|
||||||
<Code key={user.name}>{user.name}</Code>
|
<Code key={user.id}>{getUserDisplayName(user)}</Code>
|
||||||
{index < missing.length - 1 ? ', ' : '. '}
|
{index < missing.length - 1 ? ", " : ". "}
|
||||||
</>
|
</>
|
||||||
))}
|
))}
|
||||||
Their keys may not be listed correctly. Please check the server logs
|
Their keys may not be listed correctly. Please check the server logs for more information.
|
||||||
for more information.
|
</Notice>
|
||||||
</Notice>
|
) : undefined}
|
||||||
) : undefined}
|
<h1 className="mb-2 text-2xl font-medium">Pre-Auth Keys</h1>
|
||||||
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1>
|
<p className="mb-4">
|
||||||
<p className="mb-4">
|
Headscale fully supports pre-authentication keys in order to easily add devices to your
|
||||||
Headscale fully supports pre-authentication keys in order to easily add
|
Tailnet. To learn more about using pre-authentication keys, visit the{" "}
|
||||||
devices to your Tailnet. To learn more about using pre-authentication
|
<Link
|
||||||
keys, visit the{' '}
|
name="Tailscale Auth Keys documentation"
|
||||||
<Link
|
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||||
name="Tailscale Auth Keys documentation"
|
>
|
||||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
Tailscale documentation
|
||||||
>
|
</Link>
|
||||||
Tailscale documentation
|
</p>
|
||||||
</Link>
|
<AddAuthKey
|
||||||
</p>
|
currentSubject={currentSubject}
|
||||||
<AddAuthKey users={users} />
|
selfServiceOnly={selfServiceOnly}
|
||||||
<div className="flex items-center gap-4 mt-4">
|
url={url}
|
||||||
<Select
|
users={users}
|
||||||
className="w-full"
|
/>
|
||||||
defaultSelectedKey="__headplane_all"
|
<div className="mt-4 flex items-center gap-4">
|
||||||
isDisabled={isDisabled}
|
<Select
|
||||||
label="User"
|
className="w-full"
|
||||||
onSelectionChange={(value) =>
|
defaultSelectedKey="__headplane_all"
|
||||||
setSelectedUser(value?.toString() ?? '')
|
isDisabled={isDisabled}
|
||||||
}
|
label="User"
|
||||||
placeholder="Select a user"
|
onSelectionChange={(value) => setSelectedUser(value?.toString() ?? "")}
|
||||||
>
|
placeholder="Select a user"
|
||||||
{[
|
>
|
||||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
{[
|
||||||
...keys.map(({ user }) => (
|
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||||
<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>
|
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
|
||||||
<Select
|
)),
|
||||||
className="w-full"
|
...(keys.some(({ user }) => user === null)
|
||||||
defaultSelectedKey="active"
|
? [<Select.Item key="__headplane_tag_only">Tag Only</Select.Item>]
|
||||||
isDisabled={isDisabled}
|
: []),
|
||||||
label="Status"
|
]}
|
||||||
onSelectionChange={(value) =>
|
</Select>
|
||||||
setStatus((value?.toString() ?? 'active') as Status)
|
<Select
|
||||||
}
|
className="w-full"
|
||||||
placeholder="Select a status"
|
defaultSelectedKey="active"
|
||||||
>
|
isDisabled={isDisabled}
|
||||||
<Select.Item key="all">All</Select.Item>
|
label="Status"
|
||||||
<Select.Item key="active">Active</Select.Item>
|
onSelectionChange={(value) => setStatus((value?.toString() ?? "active") as Status)}
|
||||||
<Select.Item key="expired">Used/Expired</Select.Item>
|
placeholder="Select a status"
|
||||||
<Select.Item key="reusable">Reusable</Select.Item>
|
>
|
||||||
<Select.Item key="ephemeral">Ephemeral</Select.Item>
|
<Select.Item key="all">All</Select.Item>
|
||||||
</Select>
|
<Select.Item key="active">Active</Select.Item>
|
||||||
</div>
|
<Select.Item key="expired">Used/Expired</Select.Item>
|
||||||
<TableList className="mt-4">
|
<Select.Item key="reusable">Reusable</Select.Item>
|
||||||
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
|
<Select.Item key="ephemeral">Ephemeral</Select.Item>
|
||||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
</Select>
|
||||||
<FileKey2 />
|
</div>
|
||||||
<p className="font-semibold">
|
<TableList className="mt-4">
|
||||||
No pre-auth keys have been created yet.
|
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
|
||||||
</p>
|
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||||
</TableList.Item>
|
<FileKey2 />
|
||||||
) : filteredKeys.length === 0 ? (
|
<p className="font-semibold">No pre-auth keys have been created yet.</p>
|
||||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
</TableList.Item>
|
||||||
<FileKey2 />
|
) : filteredKeys.length === 0 ? (
|
||||||
<p className="font-semibold">
|
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||||
No pre-auth keys match the selected filters.
|
<FileKey2 />
|
||||||
</p>
|
<p className="font-semibold">No pre-auth keys match the selected filters.</p>
|
||||||
</TableList.Item>
|
</TableList.Item>
|
||||||
) : (
|
) : (
|
||||||
filteredKeys.map((key) => {
|
filteredKeys.map((key) => {
|
||||||
// TODO: Why is Headscale using email as the user ID here?
|
// Tag-only keys have no user
|
||||||
// https://github.com/juanfont/headscale/issues/2520
|
if (!key.user) {
|
||||||
const user = users.find((user) => user.id === key.user.id);
|
return (
|
||||||
if (!user) {
|
<TableList.Item key={key.id}>
|
||||||
return null;
|
<AuthKeyRow authKey={key} user={null} />
|
||||||
}
|
</TableList.Item>
|
||||||
|
);
|
||||||
return (
|
}
|
||||||
<TableList.Item key={key.id}>
|
|
||||||
<AuthKeyRow authKey={key} url={url} user={user} />
|
// TODO: Why is Headscale using email as the user ID here?
|
||||||
</TableList.Item>
|
// https://github.com/juanfont/headscale/issues/2520
|
||||||
);
|
const user = users.find((user) => user.id === key.user?.id);
|
||||||
})
|
if (!user) {
|
||||||
)}
|
return null;
|
||||||
</TableList>
|
}
|
||||||
</div>
|
|
||||||
);
|
return (
|
||||||
|
<TableList.Item key={key.id}>
|
||||||
|
<AuthKeyRow authKey={key} user={user} />
|
||||||
|
</TableList.Item>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</TableList>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +1,73 @@
|
|||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from "lucide-react";
|
||||||
import {
|
import { Link as RemixLink } from "react-router";
|
||||||
LoaderFunctionArgs,
|
|
||||||
Link as RemixLink,
|
|
||||||
useLoaderData,
|
|
||||||
} from 'react-router';
|
|
||||||
import Link from '~/components/Link';
|
|
||||||
import { LoadContext } from '~/server';
|
|
||||||
|
|
||||||
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
|
import Link from "~/components/Link";
|
||||||
return {
|
|
||||||
config: context.hs.writable(),
|
import type { Route } from "./+types/overview";
|
||||||
oidc: context.oidc,
|
|
||||||
};
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
|
const oidcConnector = await context.oidc?.connector.get();
|
||||||
|
return {
|
||||||
|
config: context.hs.writable(),
|
||||||
|
isOidcEnabled: oidcConnector?.isValid ?? false,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page({ loaderData: { config, isOidcEnabled } }: Route.ComponentProps) {
|
||||||
const { config, oidc } = useLoaderData<typeof loader>();
|
return (
|
||||||
|
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
|
||||||
return (
|
<div className="flex w-full flex-col sm:w-2/3">
|
||||||
<div className="flex flex-col gap-8 max-w-(--breakpoint-lg)">
|
<h1 className="mb-4 text-2xl font-medium">Settings</h1>
|
||||||
<div className="flex flex-col w-2/3">
|
<p>
|
||||||
<h1 className="text-2xl font-medium mb-4">Settings</h1>
|
The settings page is still under construction. As I'm able to add more features, I'll be
|
||||||
<p>
|
adding them here. If you require any features, feel free to open an issue on the GitHub
|
||||||
The settings page is still under construction. As I'm able to add more
|
repository.
|
||||||
features, I'll be adding them here. If you require any features, feel
|
</p>
|
||||||
free to open an issue on the GitHub repository.
|
</div>
|
||||||
</p>
|
<div className="flex w-full flex-col sm:w-2/3">
|
||||||
</div>
|
<h1 className="mb-4 text-2xl font-medium">Pre-Auth Keys</h1>
|
||||||
<div className="flex flex-col w-2/3">
|
<p>
|
||||||
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
|
Headscale fully supports pre-authentication keys in order to easily add devices to your
|
||||||
<p>
|
Tailnet. To learn more about using pre-authentication keys, visit the{" "}
|
||||||
Headscale fully supports pre-authentication keys in order to easily
|
<Link
|
||||||
add devices to your Tailnet. To learn more about using
|
name="Tailscale Auth Keys documentation"
|
||||||
pre-authentication keys, visit the{' '}
|
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||||
<Link
|
>
|
||||||
name="Tailscale Auth Keys documentation"
|
Tailscale documentation
|
||||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
</Link>
|
||||||
>
|
</p>
|
||||||
Tailscale documentation
|
</div>
|
||||||
</Link>
|
<RemixLink to="/settings/auth-keys">
|
||||||
</p>
|
<div className="flex items-center text-lg font-medium">
|
||||||
</div>
|
Manage Auth Keys
|
||||||
<RemixLink to="/settings/auth-keys">
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
<div className="text-lg font-medium flex items-center">
|
</div>
|
||||||
Manage Auth Keys
|
</RemixLink>
|
||||||
<ArrowRight className="w-5 h-5 ml-2" />
|
{config && isOidcEnabled ? (
|
||||||
</div>
|
<>
|
||||||
</RemixLink>
|
<div className="flex w-full flex-col sm:w-2/3">
|
||||||
{config && oidc ? (
|
<h1 className="mb-4 text-2xl font-medium">Authentication Restrictions</h1>
|
||||||
<>
|
<p>
|
||||||
<div className="flex flex-col w-2/3">
|
Headscale supports restricting OIDC authentication to only allow certain email
|
||||||
<h1 className="text-2xl font-medium mb-4">
|
domains, groups, or users to authenticate. This can be used to limit access to your
|
||||||
Authentication Restrictions
|
Tailnet to only certain users or groups and Headplane will also respect these settings
|
||||||
</h1>
|
when authenticating.{" "}
|
||||||
<p>
|
<Link
|
||||||
Headscale supports restricting OIDC authentication to only allow
|
name="Headscale OIDC documentation"
|
||||||
certain email domains, groups, or users to authenticate. This can
|
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||||
be used to limit access to your Tailnet to only certain users or
|
>
|
||||||
groups and Headplane will also respect these settings when
|
Learn More
|
||||||
authenticating.{' '}
|
</Link>
|
||||||
<Link
|
</p>
|
||||||
name="Headscale OIDC documentation"
|
</div>
|
||||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
<RemixLink to="/settings/restrictions">
|
||||||
>
|
<div className="flex items-center text-lg font-medium">
|
||||||
Learn More
|
Manage Restrictions
|
||||||
</Link>
|
<ArrowRight className="ml-2 h-5 w-5" />
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</RemixLink>
|
||||||
<RemixLink to="/settings/restrictions">
|
</>
|
||||||
<div className="text-lg font-medium flex items-center">
|
) : undefined}
|
||||||
Manage Restrictions
|
</div>
|
||||||
<ArrowRight className="w-5 h-5 ml-2" />
|
);
|
||||||
</div>
|
|
||||||
</RemixLink>
|
|
||||||
</>
|
|
||||||
) : undefined}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,221 +1,189 @@
|
|||||||
import { ActionFunctionArgs, data } from 'react-router';
|
import { data } from "react-router";
|
||||||
import { LoadContext } from '~/server';
|
|
||||||
import { Capabilities } from '~/server/web/roles';
|
|
||||||
|
|
||||||
export async function restrictionAction({
|
import { Capabilities } from "~/server/web/roles";
|
||||||
request,
|
|
||||||
context,
|
|
||||||
}: ActionFunctionArgs<LoadContext>) {
|
|
||||||
const check = await context.sessions.check(
|
|
||||||
request,
|
|
||||||
Capabilities.configure_iam,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!check) {
|
import type { Route } from "./+types/overview";
|
||||||
throw data('You do not have permission to modify IAM settings.', {
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.hs.writable()) {
|
export async function restrictionAction({ request, context }: Route.ActionArgs) {
|
||||||
throw data('The Headscale configuration file is not editable.', {
|
const principal = await context.auth.require(request);
|
||||||
status: 403,
|
const check = context.auth.can(principal, Capabilities.configure_iam);
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const formData = await request.formData();
|
if (!check) {
|
||||||
const action = formData.get('action_id')?.toString();
|
throw data("You do not have permission to modify IAM settings.", {
|
||||||
if (!action) {
|
status: 403,
|
||||||
throw data('No action provided.', {
|
});
|
||||||
status: 400,
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (action) {
|
if (!context.hs.writable()) {
|
||||||
case 'add_domain': {
|
throw data("The Headscale configuration file is not editable.", {
|
||||||
return addDomain(formData, context);
|
status: 403,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
case 'remove_domain': {
|
const formData = await request.formData();
|
||||||
return removeDomain(formData, context);
|
const action = formData.get("action_id")?.toString();
|
||||||
}
|
if (!action) {
|
||||||
|
throw data("No action provided.", {
|
||||||
|
status: 400,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
case 'add_group': {
|
// We only need healthchecks which don't rely on an API key
|
||||||
return addGroup(formData, context);
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
case 'remove_group': {
|
const domains = [...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain])];
|
||||||
return removeGroup(formData, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
case 'add_user': {
|
await context.hs.patch([
|
||||||
return addUser(formData, context);
|
{
|
||||||
}
|
path: "oidc.allowed_domains",
|
||||||
|
value: domains,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
case 'remove_user': {
|
context.integration?.onConfigChange(api);
|
||||||
return removeUser(formData, context);
|
return data("Domain added successfully.");
|
||||||
}
|
}
|
||||||
|
|
||||||
default: {
|
case "remove_domain": {
|
||||||
throw data('Invalid action provided.', {
|
const domain = formData.get("domain")?.toString()?.trim();
|
||||||
status: 400,
|
if (!domain) {
|
||||||
});
|
throw data("No domain provided.", {
|
||||||
}
|
status: 400,
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addDomain(formData: FormData, context: LoadContext) {
|
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
|
||||||
const domain = formData.get('domain')?.toString()?.trim();
|
if (!storedDomains.includes(domain)) {
|
||||||
if (!domain) {
|
// Domain not found in the list
|
||||||
throw data('No domain provided.', {
|
throw data(`Domain "${domain}" not found in allowed domains.`, {
|
||||||
status: 400,
|
status: 400,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const domains = [
|
// Filter out the domain to remove it from the list
|
||||||
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
|
const domains = storedDomains.filter((d: string) => d !== domain);
|
||||||
];
|
await context.hs.patch([
|
||||||
|
{
|
||||||
await context.hs.patch([
|
path: "oidc.allowed_domains",
|
||||||
{
|
value: domains,
|
||||||
path: 'oidc.allowed_domains',
|
},
|
||||||
value: domains,
|
]);
|
||||||
},
|
context.integration?.onConfigChange(api);
|
||||||
]);
|
return data("Domain removed successfully.");
|
||||||
|
}
|
||||||
context.integration?.onConfigChange(context.client);
|
|
||||||
return data('Domain added successfully.');
|
case "add_group": {
|
||||||
}
|
const group = formData.get("group")?.toString()?.trim();
|
||||||
|
if (!group) {
|
||||||
async function removeDomain(formData: FormData, context: LoadContext) {
|
throw data("No group provided.", {
|
||||||
const domain = formData.get('domain')?.toString()?.trim();
|
status: 400,
|
||||||
if (!domain) {
|
});
|
||||||
throw data('No domain provided.', {
|
}
|
||||||
status: 400,
|
|
||||||
});
|
const groups = [...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group])];
|
||||||
}
|
|
||||||
|
await context.hs.patch([
|
||||||
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
|
{
|
||||||
if (!storedDomains.includes(domain)) {
|
path: "oidc.allowed_groups",
|
||||||
// Domain not found in the list
|
value: groups,
|
||||||
throw data(`Domain "${domain}" not found in allowed domains.`, {
|
},
|
||||||
status: 400,
|
]);
|
||||||
});
|
|
||||||
}
|
context.integration?.onConfigChange(api);
|
||||||
|
return data("Group added successfully.");
|
||||||
// Filter out the domain to remove it from the list
|
}
|
||||||
const domains = storedDomains.filter((d: string) => d !== domain);
|
|
||||||
await context.hs.patch([
|
case "remove_group": {
|
||||||
{
|
const group = formData.get("group")?.toString()?.trim();
|
||||||
path: 'oidc.allowed_domains',
|
if (!group) {
|
||||||
value: domains,
|
throw data("No group provided.", {
|
||||||
},
|
status: 400,
|
||||||
]);
|
});
|
||||||
|
}
|
||||||
context.integration?.onConfigChange(context.client);
|
|
||||||
return data('Domain removed successfully.');
|
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
|
||||||
}
|
if (!storedGroups.includes(group)) {
|
||||||
|
// Group not found in the list
|
||||||
async function addUser(formData: FormData, context: LoadContext) {
|
throw data(`Group "${group}" not found in allowed groups.`, {
|
||||||
const user = formData.get('user')?.toString()?.trim();
|
status: 400,
|
||||||
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([
|
||||||
const users = [
|
{
|
||||||
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
|
path: "oidc.allowed_groups",
|
||||||
];
|
value: groups,
|
||||||
|
},
|
||||||
await context.hs.patch([
|
]);
|
||||||
{
|
|
||||||
path: 'oidc.allowed_users',
|
context.integration?.onConfigChange(api);
|
||||||
value: users,
|
return data("Group removed successfully.");
|
||||||
},
|
}
|
||||||
]);
|
|
||||||
|
case "add_user": {
|
||||||
context.integration?.onConfigChange(context.client);
|
const user = formData.get("user")?.toString()?.trim();
|
||||||
return data('User added successfully.');
|
if (!user) {
|
||||||
}
|
throw data("No user provided.", {
|
||||||
|
status: 400,
|
||||||
async function removeUser(formData: FormData, context: LoadContext) {
|
});
|
||||||
const user = formData.get('user')?.toString()?.trim();
|
}
|
||||||
if (!user) {
|
|
||||||
throw data('No user provided.', {
|
const users = [...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user])];
|
||||||
status: 400,
|
|
||||||
});
|
await context.hs.patch([
|
||||||
}
|
{
|
||||||
|
path: "oidc.allowed_users",
|
||||||
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
|
value: 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
|
case "remove_user": {
|
||||||
const users = storedUsers.filter((d: string) => d !== user);
|
const user = formData.get("user")?.toString()?.trim();
|
||||||
await context.hs.patch([
|
if (!user) {
|
||||||
{
|
throw data("No user provided.", {
|
||||||
path: 'oidc.allowed_users',
|
status: 400,
|
||||||
value: users,
|
});
|
||||||
},
|
}
|
||||||
]);
|
|
||||||
|
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
|
||||||
context.integration?.onConfigChange(context.client);
|
if (!storedUsers.includes(user)) {
|
||||||
return data('User removed successfully.');
|
// User not found in the list
|
||||||
}
|
throw data(`User "${user}" not found in allowed users.`, {
|
||||||
|
status: 400,
|
||||||
async function addGroup(formData: FormData, context: LoadContext) {
|
});
|
||||||
const group = formData.get('group')?.toString()?.trim();
|
}
|
||||||
if (!group) {
|
|
||||||
throw data('No group provided.', {
|
// Filter out the user to remove it from the list
|
||||||
status: 400,
|
const users = storedUsers.filter((d: string) => d !== user);
|
||||||
});
|
await context.hs.patch([
|
||||||
}
|
{
|
||||||
|
path: "oidc.allowed_users",
|
||||||
const groups = [
|
value: users,
|
||||||
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
|
},
|
||||||
];
|
]);
|
||||||
|
|
||||||
await context.hs.patch([
|
context.integration?.onConfigChange(api);
|
||||||
{
|
return data("User removed successfully.");
|
||||||
path: 'oidc.allowed_groups',
|
}
|
||||||
value: groups,
|
|
||||||
},
|
default: {
|
||||||
]);
|
throw data("Invalid action provided.", {
|
||||||
|
status: 400,
|
||||||
context.integration?.onConfigChange(context.client);
|
});
|
||||||
return data('Group added successfully.');
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeGroup(formData: FormData, context: LoadContext) {
|
|
||||||
const group = formData.get('group')?.toString()?.trim();
|
|
||||||
if (!group) {
|
|
||||||
throw data('No group provided.', {
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
|
|
||||||
if (!storedGroups.includes(group)) {
|
|
||||||
// Group not found in the list
|
|
||||||
throw data(`Group "${group}" not found in allowed groups.`, {
|
|
||||||
status: 400,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out the group to remove it from the list
|
|
||||||
const groups = storedGroups.filter((d: string) => d !== group);
|
|
||||||
await context.hs.patch([
|
|
||||||
{
|
|
||||||
path: 'oidc.allowed_groups',
|
|
||||||
value: groups,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
context.integration?.onConfigChange(context.client);
|
|
||||||
return data('Group removed successfully.');
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,19 +39,19 @@ export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
|
|||||||
Add this domain to a list of allowed email domains that can
|
Add this domain to a list of allowed email domains that can
|
||||||
authenticate with Headscale via OIDC.
|
authenticate with Headscale via OIDC.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<input type="hidden" name="action_id" value="add_domain" />
|
<input name="action_id" type="hidden" value="add_domain" />
|
||||||
<Input
|
<Input
|
||||||
isRequired
|
|
||||||
label="Domain"
|
|
||||||
description={
|
description={
|
||||||
domain.trim().length > 0
|
domain.trim().length > 0
|
||||||
? `Matches users with <user>@${domain.trim()}`
|
? `Matches users with <user>@${domain.trim()}`
|
||||||
: 'Enter a domain to match users with their email addresses.'
|
: 'Enter a domain to match users with their email addresses.'
|
||||||
}
|
}
|
||||||
placeholder="example.com"
|
isInvalid={domain.trim().length === 0 || isInvalid}
|
||||||
|
isRequired
|
||||||
|
label="Domain"
|
||||||
name="domain"
|
name="domain"
|
||||||
onChange={setDomain}
|
onChange={setDomain}
|
||||||
isInvalid={domain.trim().length === 0 || isInvalid}
|
placeholder="example.com"
|
||||||
/>
|
/>
|
||||||
{isInvalid && (
|
{isInvalid && (
|
||||||
<p className="text-red-500 text-sm mt-2">
|
<p className="text-red-500 text-sm mt-2">
|
||||||
|
|||||||
@@ -30,15 +30,15 @@ export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
|
|||||||
Add this group to a list of allowed groups that can authenticate with
|
Add this group to a list of allowed groups that can authenticate with
|
||||||
Headscale via OIDC.
|
Headscale via OIDC.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<input type="hidden" name="action_id" value="add_group" />
|
<input name="action_id" type="hidden" value="add_group" />
|
||||||
<Input
|
<Input
|
||||||
|
description="The group to allow for OIDC authentication."
|
||||||
|
isInvalid={group.trim().length === 0 || isInvalid}
|
||||||
isRequired
|
isRequired
|
||||||
label="Group"
|
label="Group"
|
||||||
description="The group to allow for OIDC authentication."
|
|
||||||
placeholder="admin"
|
|
||||||
name="group"
|
name="group"
|
||||||
onChange={setGroup}
|
onChange={setGroup}
|
||||||
isInvalid={group.trim().length === 0 || isInvalid}
|
placeholder="admin"
|
||||||
/>
|
/>
|
||||||
{isInvalid && (
|
{isInvalid && (
|
||||||
<p className="text-red-500 text-sm mt-2">
|
<p className="text-red-500 text-sm mt-2">
|
||||||
|
|||||||
@@ -30,15 +30,15 @@ export default function AddUser({ users, isDisabled }: AddUserProps) {
|
|||||||
Add this user to a list of allowed users that can authenticate with
|
Add this user to a list of allowed users that can authenticate with
|
||||||
Headscale via OIDC.
|
Headscale via OIDC.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<input type="hidden" name="action_id" value="add_user" />
|
<input name="action_id" type="hidden" value="add_user" />
|
||||||
<Input
|
<Input
|
||||||
|
description="The user to allow for OIDC authentication."
|
||||||
|
isInvalid={user.trim().length === 0 || isInvalid}
|
||||||
isRequired
|
isRequired
|
||||||
label="User"
|
label="User"
|
||||||
description="The user to allow for OIDC authentication."
|
|
||||||
placeholder="john_doe"
|
|
||||||
name="user"
|
name="user"
|
||||||
onChange={setUser}
|
onChange={setUser}
|
||||||
isInvalid={user.trim().length === 0 || isInvalid}
|
placeholder="john_doe"
|
||||||
/>
|
/>
|
||||||
{isInvalid && (
|
{isInvalid && (
|
||||||
<p className="text-red-500 text-sm mt-2">
|
<p className="text-red-500 text-sm mt-2">
|
||||||
|
|||||||
@@ -1,118 +1,91 @@
|
|||||||
import {
|
import { data, Link as RemixLink } from "react-router";
|
||||||
ActionFunctionArgs,
|
|
||||||
LoaderFunctionArgs,
|
|
||||||
Link as RemixLink,
|
|
||||||
data,
|
|
||||||
useLoaderData,
|
|
||||||
} from 'react-router';
|
|
||||||
import Link from '~/components/Link';
|
|
||||||
import Notice from '~/components/Notice';
|
|
||||||
import { LoadContext } from '~/server';
|
|
||||||
import { Capabilities } from '~/server/web/roles';
|
|
||||||
import { restrictionAction } from './actions';
|
|
||||||
import AddDomain from './dialogs/add-domain';
|
|
||||||
import AddGroup from './dialogs/add-group';
|
|
||||||
import AddUser from './dialogs/add-user';
|
|
||||||
import RestrictionTable from './table';
|
|
||||||
|
|
||||||
export async function loader({
|
import Link from "~/components/Link";
|
||||||
request,
|
import Notice from "~/components/Notice";
|
||||||
context,
|
import { Capabilities } from "~/server/web/roles";
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
|
||||||
const check = await context.sessions.check(request, Capabilities.read_users);
|
|
||||||
if (!check) {
|
|
||||||
throw data('You do not have permission to view IAM settings.', {
|
|
||||||
status: 403,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!context.hs.c?.oidc) {
|
import type { Route } from "./+types/overview";
|
||||||
throw data('OIDC is not configured on this Headscale instance.', {
|
import { restrictionAction } from "./actions";
|
||||||
status: 501,
|
import AddDomain from "./dialogs/add-domain";
|
||||||
});
|
import AddGroup from "./dialogs/add-group";
|
||||||
}
|
import AddUser from "./dialogs/add-user";
|
||||||
|
import RestrictionTable from "./table";
|
||||||
|
|
||||||
return {
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
access: await context.sessions.check(request, Capabilities.configure_iam),
|
const principal = await context.auth.require(request);
|
||||||
writable: context.hs.writable(),
|
const check = context.auth.can(principal, Capabilities.read_users);
|
||||||
settings: {
|
if (!check) {
|
||||||
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
|
throw data("You do not have permission to view IAM settings.", {
|
||||||
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
|
status: 403,
|
||||||
users: [...new Set(context.hs.c.oidc.allowed_users)],
|
});
|
||||||
},
|
}
|
||||||
};
|
|
||||||
|
if (!context.hs.c?.oidc) {
|
||||||
|
throw data("OIDC is not configured on this Headscale instance.", {
|
||||||
|
status: 501,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 async function action(request: ActionFunctionArgs) {
|
export const action = restrictionAction;
|
||||||
return restrictionAction(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page({ loaderData: { access, writable, settings } }: Route.ComponentProps) {
|
||||||
const { access, writable, settings } = useLoaderData<typeof loader>();
|
const isDisabled = writable ? !access : true;
|
||||||
const isDisabled = writable ? !access : true;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
|
<div className="flex max-w-(--breakpoint-lg) flex-col gap-4">
|
||||||
<div className="flex flex-col w-2/3">
|
<div className="flex w-full flex-col sm:w-2/3">
|
||||||
<p className="mb-4 text-md">
|
<p className="text-md mb-4">
|
||||||
<RemixLink to="/settings" className="font-medium">
|
<RemixLink className="font-medium" to="/settings">
|
||||||
Settings
|
Settings
|
||||||
</RemixLink>
|
</RemixLink>
|
||||||
<span className="mx-2">/</span> Authentication Restrictions
|
<span className="mx-2">/</span> Authentication Restrictions
|
||||||
</p>
|
</p>
|
||||||
{!access ? (
|
{!access ? (
|
||||||
<Notice
|
<Notice title="Authentication permissions restricted" variant="warning">
|
||||||
title="Authentication permissions restricted"
|
You do not have the necessary permissions to edit the Authentication Restrictions
|
||||||
variant="warning"
|
settings. Please contact your administrator to request access or to make changes to
|
||||||
>
|
these settings.
|
||||||
You do not have the necessary permissions to edit the Authentication
|
</Notice>
|
||||||
Restrictions settings. Please contact your administrator to request
|
) : !writable ? (
|
||||||
access or to make changes to these settings.
|
<Notice title="Configuration Locked" variant="error">
|
||||||
</Notice>
|
The Headscale configuration file is not editable through the web interface. Please
|
||||||
) : !writable ? (
|
ensure that you have correctly given Headplane write access to the file.
|
||||||
<Notice title="Configuration Locked" variant="error">
|
</Notice>
|
||||||
The Headscale configuration file is not editable through the web
|
) : undefined}
|
||||||
interface. Please ensure that you have correctly given Headplane
|
<h1 className="mt-4 mb-2 text-2xl font-medium">Authentication Restrictions</h1>
|
||||||
write access to the file.
|
<p>
|
||||||
</Notice>
|
Headscale supports restricting OIDC authentication to only allow certain email domains,
|
||||||
) : undefined}
|
groups, or users to authenticate. This can be used to limit access to your Tailnet to only
|
||||||
<h1 className="text-2xl font-medium mb-2 mt-4">
|
certain users or groups and Headplane will also respect these settings when
|
||||||
Authentication Restrictions
|
authenticating.{" "}
|
||||||
</h1>
|
<Link
|
||||||
<p>
|
name="Headscale OIDC documentation"
|
||||||
Headscale supports restricting OIDC authentication to only allow
|
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||||
certain email domains, groups, or users to authenticate. This can be
|
>
|
||||||
used to limit access to your Tailnet to only certain users or groups
|
Learn More
|
||||||
and Headplane will also respect these settings when authenticating.{' '}
|
</Link>
|
||||||
<Link
|
</p>
|
||||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
</div>
|
||||||
name="Headscale OIDC documentation"
|
<RestrictionTable isDisabled={isDisabled} type="domain" values={settings.domains}>
|
||||||
>
|
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
|
||||||
Learn More
|
</RestrictionTable>
|
||||||
</Link>
|
<RestrictionTable isDisabled={isDisabled} type="group" values={settings.groups}>
|
||||||
</p>
|
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
|
||||||
</div>
|
</RestrictionTable>
|
||||||
<RestrictionTable
|
<RestrictionTable isDisabled={isDisabled} type="user" values={settings.users}>
|
||||||
type="domain"
|
<AddUser isDisabled={isDisabled} users={settings.users} />
|
||||||
values={settings.domains}
|
</RestrictionTable>
|
||||||
isDisabled={isDisabled}
|
</div>
|
||||||
>
|
);
|
||||||
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
|
|
||||||
</RestrictionTable>
|
|
||||||
<RestrictionTable
|
|
||||||
type="group"
|
|
||||||
values={settings.groups}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
>
|
|
||||||
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
|
|
||||||
</RestrictionTable>
|
|
||||||
<RestrictionTable
|
|
||||||
type="user"
|
|
||||||
values={settings.users}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
>
|
|
||||||
<AddUser users={settings.users} isDisabled={isDisabled} />
|
|
||||||
</RestrictionTable>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export default function RestrictionTable({
|
|||||||
isDisabled,
|
isDisabled,
|
||||||
}: RestrictionProps) {
|
}: RestrictionProps) {
|
||||||
return (
|
return (
|
||||||
<div className="w-2/3">
|
<div className="w-full sm:w-2/3">
|
||||||
<h2 className="text-2xl font-medium mt-8">
|
<h2 className="text-2xl font-medium mt-8">
|
||||||
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
|
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
|
||||||
</h2>
|
</h2>
|
||||||
@@ -40,18 +40,18 @@ export default function RestrictionTable({
|
|||||||
)}
|
)}
|
||||||
<Form method="POST">
|
<Form method="POST">
|
||||||
<input
|
<input
|
||||||
type="hidden"
|
|
||||||
name="action_id"
|
name="action_id"
|
||||||
|
type="hidden"
|
||||||
value={`remove_${type}`}
|
value={`remove_${type}`}
|
||||||
/>
|
/>
|
||||||
<input type="hidden" name={type} value={value} />
|
<input name={type} type="hidden" value={value} />
|
||||||
<Button
|
<Button
|
||||||
isDisabled={isDisabled}
|
|
||||||
type="submit"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'px-2 py-1 rounded-md',
|
'px-2 py-1 rounded-md',
|
||||||
'text-red-500 dark:text-red-400',
|
'text-red-500 dark:text-red-400',
|
||||||
)}
|
)}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
type="submit"
|
||||||
>
|
>
|
||||||
Remove
|
Remove
|
||||||
</Button>
|
</Button>
|
||||||
@@ -61,7 +61,7 @@ export default function RestrictionTable({
|
|||||||
) : (
|
) : (
|
||||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||||
{iconForType(type)}
|
{iconForType(type)}
|
||||||
<p className="font-semibold">
|
<p className="font-semibold text-center">
|
||||||
All {type}s are permitted to authenticate.
|
All {type}s are permitted to authenticate.
|
||||||
</p>
|
</p>
|
||||||
</TableList.Item>
|
</TableList.Item>
|
||||||
|
|||||||
+223
-268
@@ -1,316 +1,271 @@
|
|||||||
/** biome-ignore-all lint/correctness/noNestedComponentDefinitions: Wtf? */
|
import { faker } from "@faker-js/faker";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { data, type ShouldRevalidateFunction, useSubmit } from "react-router";
|
||||||
|
import { ExternalScriptsHandle } from "remix-utils/external-scripts";
|
||||||
|
|
||||||
import { faker } from '@faker-js/faker';
|
import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema";
|
||||||
import { eq } from 'drizzle-orm';
|
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
||||||
import { Loader2 } from 'lucide-react';
|
import { useLiveData } from "~/utils/live-data";
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import {
|
import type { Route } from "./+types/console";
|
||||||
ActionFunctionArgs,
|
import UserPrompt from "./user-prompt";
|
||||||
data,
|
import XTerm from "./xterm.client";
|
||||||
LinksFunction,
|
|
||||||
LoaderFunctionArgs,
|
|
||||||
ShouldRevalidateFunction,
|
|
||||||
useLoaderData,
|
|
||||||
useSubmit,
|
|
||||||
} from 'react-router';
|
|
||||||
import { ExternalScriptsHandle } from 'remix-utils/external-scripts';
|
|
||||||
import { LoadContext } from '~/server';
|
|
||||||
import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
|
|
||||||
import { Machine, PreAuthKey, User } from '~/types';
|
|
||||||
import { useLiveData } from '~/utils/live-data';
|
|
||||||
import UserPrompt from './user-prompt';
|
|
||||||
import XTerm from './xterm.client';
|
|
||||||
|
|
||||||
export const shouldRevalidate: ShouldRevalidateFunction = () => {
|
export const shouldRevalidate: ShouldRevalidateFunction = () => {
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function loader({
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
request,
|
const origin = new URL(request.url).origin;
|
||||||
context,
|
const assets = ["/wasm_exec.js", "/hp_ssh.wasm"];
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
const missing: string[] = [];
|
||||||
const origin = new URL(request.url).origin;
|
|
||||||
const assets = ['/wasm_exec.js', '/hp_ssh.wasm'];
|
|
||||||
const missing: string[] = [];
|
|
||||||
|
|
||||||
for (const file of assets) {
|
for (const file of assets) {
|
||||||
const res = await fetch(`${origin}${file}`, { method: 'HEAD' });
|
const res = await fetch(`${origin}${file}`, { method: "HEAD" });
|
||||||
if (!res.ok) missing.push(file);
|
if (!res.ok) missing.push(file);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (missing.length > 0) {
|
if (missing.length > 0) {
|
||||||
throw data('WebSSH is not configured in this build.', 405);
|
throw data("WebSSH is not configured in this build.", 405);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!context.agents?.agentID()) {
|
if (!context.agents?.agentID()) {
|
||||||
throw data(
|
throw data("WebSSH is only available with the Headplane agent integration", 400);
|
||||||
'WebSSH is only available with the Headplane agent integration',
|
}
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await context.sessions.auth(request);
|
const principal = await context.auth.require(request);
|
||||||
if (session.user.subject === 'unknown-non-oauth') {
|
if (principal.kind === "api_key") {
|
||||||
throw data('Only OAuth users are allowed to use WebSSH', 403);
|
throw data("Only OAuth users are allowed to use WebSSH", 403);
|
||||||
}
|
}
|
||||||
const { users } = await context.client.get<{ users: User[] }>(
|
|
||||||
'v1/user',
|
|
||||||
session.api_key,
|
|
||||||
);
|
|
||||||
|
|
||||||
// MARK: This assumes that a user has authenticated with Headscale first
|
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||||
// Since the only way to enforce permissions via ACLs is to generate a
|
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||||
// pre-authkey which REQUIRES a user ID, meaning the user has to have
|
const users = await api.getUsers();
|
||||||
// authenticated with Headscale first.
|
|
||||||
const lookup = users.find((u) => {
|
|
||||||
const subject = u.providerId?.split('/').pop();
|
|
||||||
if (!subject) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
return subject === session.user.subject;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!lookup) {
|
// MARK: This assumes that a user has authenticated with Headscale first
|
||||||
throw data(
|
// Since the only way to enforce permissions via ACLs is to generate a
|
||||||
`User with subject ${session.user.subject} not found within Headscale`,
|
// pre-authkey which REQUIRES a user ID, meaning the user has to have
|
||||||
404,
|
// authenticated with Headscale first.
|
||||||
);
|
const lookup = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
|
||||||
}
|
|
||||||
|
|
||||||
const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
|
if (!lookup) {
|
||||||
'v1/preauthkey',
|
throw data(`User with subject ${principal.user.subject} not found within Headscale`, 404);
|
||||||
session.api_key,
|
}
|
||||||
{
|
|
||||||
user: lookup.id,
|
|
||||||
reusable: false,
|
|
||||||
ephemeral: true,
|
|
||||||
expiration: new Date(Date.now() + 60 * 1000).toISOString(), // 1 minute
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
// TODO: Enable config to enforce generate_authkeys capability
|
const preAuthKey = await api.createPreAuthKey(
|
||||||
// For now, any user is capable of WebSSH connections
|
lookup.id,
|
||||||
// const check = await context.sessions.check(
|
true, // ephemeral
|
||||||
// request,
|
false, // reusable
|
||||||
// Capabilities.generate_authkeys,
|
new Date(Date.now() + 60 * 1000), // expiration: 1 minute
|
||||||
// );
|
null, // aclTags
|
||||||
|
);
|
||||||
|
|
||||||
const qp = new URL(request.url).searchParams;
|
// TODO: Enable config to enforce generate_authkeys capability
|
||||||
const username = qp.get('username') || undefined;
|
// For now, any user is capable of WebSSH connections
|
||||||
const hostname = qp.get('hostname') || undefined;
|
// const check = await context.sessions.check(
|
||||||
if (!hostname) {
|
// request,
|
||||||
throw data('Missing required parameter: hostname', 400);
|
// Capabilities.generate_authkeys,
|
||||||
}
|
// );
|
||||||
|
|
||||||
if (!username) {
|
const qp = new URL(request.url).searchParams;
|
||||||
return {
|
const username = qp.get("username") || undefined;
|
||||||
ipnDetails: undefined,
|
const hostname = qp.get("hostname") || undefined;
|
||||||
sshDetails: {
|
if (!hostname) {
|
||||||
username,
|
throw data("Missing required parameter: hostname", 400);
|
||||||
hostname,
|
}
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// We're making a request to <url>/key?v=116 to check the CORS headers
|
if (!username) {
|
||||||
const u = context.config.headscale.public_url ?? context.config.headscale.url;
|
return {
|
||||||
// const res = await fetch(`${u}/key?v=116`, {
|
ipnDetails: undefined,
|
||||||
// method: 'GET',
|
sshDetails: {
|
||||||
// });
|
username,
|
||||||
|
hostname,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// const corsOrigin = res.headers.get('Access-Control-Allow-Origin');
|
// We're making a request to <url>/key?v=116 to check the CORS headers
|
||||||
// const corsMethods = res.headers.get('Access-Control-Allow-Methods');
|
const u = context.config.headscale.public_url ?? context.config.headscale.url;
|
||||||
// const corsHeaders = res.headers.get('Access-Control-Allow-Headers');
|
// const res = await fetch(`${u}/key?v=116`, {
|
||||||
// console.log(corsOrigin, corsMethods, corsHeaders);
|
// method: 'GET',
|
||||||
|
// });
|
||||||
|
|
||||||
// if (!corsOrigin || !corsMethods || !corsHeaders) {
|
// const corsOrigin = res.headers.get('Access-Control-Allow-Origin');
|
||||||
// throw data(
|
// const corsMethods = res.headers.get('Access-Control-Allow-Methods');
|
||||||
// 'Headscale server does not have the required CORS headers for WebSSH',
|
// const corsHeaders = res.headers.get('Access-Control-Allow-Headers');
|
||||||
// 500,
|
// console.log(corsOrigin, corsMethods, corsHeaders);
|
||||||
// );
|
|
||||||
// }
|
|
||||||
|
|
||||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
// if (!corsOrigin || !corsMethods || !corsHeaders) {
|
||||||
'v1/node',
|
// throw data(
|
||||||
session.api_key,
|
// 'Headscale server does not have the required CORS headers for WebSSH',
|
||||||
);
|
// 500,
|
||||||
|
// );
|
||||||
|
// }
|
||||||
|
|
||||||
// node.name is the hostname, given_name is the set name
|
const nodes = await api.getNodes();
|
||||||
const lookupNode = nodes.find((n) => n.name === hostname);
|
const lookupNode = nodes.find((n) => n.givenName === hostname);
|
||||||
if (!lookupNode) {
|
if (!lookupNode) {
|
||||||
throw data(`Node with hostname ${hostname} not found`, 404);
|
throw data(`Node with hostname ${hostname} not found`, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Last thing is keeping track of the ephemeral node in the database
|
// Last thing is keeping track of the ephemeral node in the database
|
||||||
// because Headscale doesn't automatically delete ephemeral nodes???
|
// because Headscale doesn't automatically delete ephemeral nodes???
|
||||||
const [_ephemeralNode] = await context.db
|
const [_ephemeralNode] = await context.db
|
||||||
.insert(ephemeralNodes)
|
.insert(ephemeralNodes)
|
||||||
.values({
|
.values({
|
||||||
auth_key: preAuthKey.key,
|
auth_key: preAuthKey.key,
|
||||||
} satisfies EphemeralNodeInsert)
|
} satisfies EphemeralNodeInsert)
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ipnDetails: {
|
ipnDetails: {
|
||||||
PreAuthKey: preAuthKey.key,
|
PreAuthKey: preAuthKey.key,
|
||||||
Hostname: generateHostname(username),
|
Hostname: generateHostname(username),
|
||||||
ControlURL: u,
|
ControlURL: u,
|
||||||
},
|
},
|
||||||
|
|
||||||
sshDetails: {
|
sshDetails: {
|
||||||
username,
|
username,
|
||||||
hostname,
|
hostname,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateHostname(username: string) {
|
function generateHostname(username: string) {
|
||||||
const adjective = faker.word.adjective({
|
const adjective = faker.word.adjective({
|
||||||
length: {
|
length: {
|
||||||
min: 3,
|
min: 3,
|
||||||
max: 6,
|
max: 6,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const noun = faker.word.noun({
|
const noun = faker.word.noun({
|
||||||
length: {
|
length: {
|
||||||
min: 3,
|
min: 3,
|
||||||
max: 6,
|
max: 6,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return `ssh-${adjective}-${noun}-${username}`;
|
return `ssh-${adjective}-${noun}-${username}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function action({
|
export async function action({ request, context }: Route.ActionArgs) {
|
||||||
request,
|
await context.auth.require(request);
|
||||||
context,
|
if (!context.agents?.agentID()) {
|
||||||
}: ActionFunctionArgs<LoadContext>) {
|
throw data("WebSSH is only available with the Headplane agent integration", 400);
|
||||||
const _session = await context.sessions.auth(request);
|
}
|
||||||
if (!context.agents?.agentID()) {
|
|
||||||
throw data(
|
|
||||||
'WebSSH is only available with the Headplane agent integration',
|
|
||||||
400,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const form = await request.formData();
|
const form = await request.formData();
|
||||||
const nodeKey = form.get('node_key');
|
const nodeKey = form.get("node_key");
|
||||||
const authKey = form.get('auth_key');
|
const authKey = form.get("auth_key");
|
||||||
|
|
||||||
if (nodeKey === null || typeof nodeKey !== 'string') {
|
if (nodeKey === null || typeof nodeKey !== "string") {
|
||||||
throw data('Missing node_key', 400);
|
throw data("Missing node_key", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (authKey === null || typeof authKey !== 'string') {
|
if (authKey === null || typeof authKey !== "string") {
|
||||||
throw data('Missing auth_key', 400);
|
throw data("Missing auth_key", 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
await context.db
|
await context.db
|
||||||
.update(ephemeralNodes)
|
.update(ephemeralNodes)
|
||||||
.set({
|
.set({
|
||||||
node_key: nodeKey,
|
node_key: nodeKey,
|
||||||
})
|
})
|
||||||
.where(eq(ephemeralNodes.auth_key, authKey));
|
.where(eq(ephemeralNodes.auth_key, authKey));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const links: LinksFunction = () => [
|
export const links: Route.LinksFunction = () => [
|
||||||
{
|
{
|
||||||
rel: 'preload',
|
rel: "preload",
|
||||||
href: '/hp_ssh.wasm',
|
href: "/hp_ssh.wasm",
|
||||||
as: 'fetch',
|
as: "fetch",
|
||||||
type: 'application/wasm',
|
type: "application/wasm",
|
||||||
crossOrigin: 'anonymous',
|
crossOrigin: "anonymous",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const handle: ExternalScriptsHandle = {
|
export const handle: ExternalScriptsHandle = {
|
||||||
scripts: [
|
scripts: [
|
||||||
{
|
{
|
||||||
src: '/wasm_exec.js',
|
src: "/wasm_exec.js",
|
||||||
crossOrigin: 'anonymous',
|
crossOrigin: "anonymous",
|
||||||
preload: true,
|
preload: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page({ loaderData: { ipnDetails, sshDetails } }: Route.ComponentProps) {
|
||||||
const submit = useSubmit();
|
const submit = useSubmit();
|
||||||
const { pause } = useLiveData();
|
const { pause } = useLiveData();
|
||||||
|
|
||||||
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
||||||
const [nodeKey, setNodeKey] = useState<string | null>(null);
|
const [nodeKey, setNodeKey] = useState<string | null>(null);
|
||||||
const { ipnDetails, sshDetails } = useLoaderData<typeof loader>();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!ipnDetails) {
|
if (!ipnDetails) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
pause();
|
pause();
|
||||||
const go = new Go(); // Go is defined by wasm_exec.js
|
const go = new Go(); // Go is defined by wasm_exec.js
|
||||||
WebAssembly.instantiateStreaming(
|
WebAssembly.instantiateStreaming(fetch("/hp_ssh.wasm"), go.importObject).then((value) => {
|
||||||
fetch('/hp_ssh.wasm'),
|
go.run(value.instance);
|
||||||
go.importObject,
|
const handle = TsWasmNet(ipnDetails, {
|
||||||
).then((value) => {
|
NotifyState: (state) => {
|
||||||
go.run(value.instance);
|
console.log("State changed:", state);
|
||||||
const handle = TsWasmNet(ipnDetails, {
|
if (state === "Running") {
|
||||||
NotifyState: (state) => {
|
setIpn(handle);
|
||||||
console.log('State changed:', state);
|
}
|
||||||
if (state === 'Running') {
|
},
|
||||||
setIpn(handle);
|
NotifyNetMap: (netmap) => {
|
||||||
}
|
// Only set NodeKey if it is not already set and then
|
||||||
},
|
// also dispatch that to the backend to track the
|
||||||
NotifyNetMap: (netmap) => {
|
// ephemeral node.
|
||||||
// Only set NodeKey if it is not already set and then
|
//
|
||||||
// also dispatch that to the backend to track the
|
// We open an SSE connection to the backend
|
||||||
// ephemeral node.
|
// so that when the connection is closed,
|
||||||
//
|
// the backend can delete the ephemeral node.
|
||||||
// We open an SSE connection to the backend
|
if (nodeKey === null) {
|
||||||
// so that when the connection is closed,
|
setNodeKey(netmap.NodeKey);
|
||||||
// the backend can delete the ephemeral node.
|
submit(
|
||||||
if (nodeKey === null) {
|
{
|
||||||
setNodeKey(netmap.NodeKey);
|
node_key: netmap.NodeKey,
|
||||||
submit(
|
auth_key: ipnDetails.PreAuthKey,
|
||||||
{
|
},
|
||||||
node_key: netmap.NodeKey,
|
{ method: "POST" },
|
||||||
auth_key: ipnDetails.PreAuthKey,
|
);
|
||||||
},
|
}
|
||||||
{ method: 'POST' },
|
},
|
||||||
);
|
NotifyBrowseToURL: (url) => {
|
||||||
}
|
console.log("Browse to URL:", url);
|
||||||
},
|
},
|
||||||
NotifyBrowseToURL: (url) => {
|
NotifyPanicRecover: (message) => {
|
||||||
console.log('Browse to URL:', url);
|
console.error("Panic recover:", message);
|
||||||
},
|
},
|
||||||
NotifyPanicRecover: (message) => {
|
});
|
||||||
console.error('Panic recover:', message);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
handle.Start();
|
handle.Start();
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!sshDetails.username) {
|
if (!sshDetails.username) {
|
||||||
return <UserPrompt hostname={sshDetails.hostname} />;
|
return <UserPrompt hostname={sshDetails.hostname} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-screen h-screen bg-headplane-900">
|
<div className="bg-headplane-900 h-screen w-screen">
|
||||||
{ipn === null ? (
|
{ipn === null ? (
|
||||||
<div className="mx-auto h-screen flex items-center justify-center">
|
<div className="mx-auto flex h-screen items-center justify-center">
|
||||||
<Loader2 className="animate-spin size-10 text-headplane-50" />
|
<Loader2 className="text-headplane-50 size-10 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col h-screen">
|
<div className="flex h-screen flex-col">
|
||||||
<XTerm
|
<XTerm hostname={sshDetails.hostname} ipn={ipn} username={sshDetails.username} />
|
||||||
hostname={sshDetails.hostname}
|
</div>
|
||||||
ipn={ipn}
|
)}
|
||||||
username={sshDetails.username}
|
</div>
|
||||||
/>
|
);
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import { Building2, House, Key } from 'lucide-react';
|
import { Building2, House, Key } from 'lucide-react';
|
||||||
import Card from '~/components/Card';
|
import Card from '~/components/Card';
|
||||||
import Link from '~/components/Link';
|
import Link from '~/components/Link';
|
||||||
import type { HeadplaneConfig } from '~/server/config/schema';
|
|
||||||
import CreateUser from '../dialogs/create-user';
|
import CreateUser from '../dialogs/create-user';
|
||||||
|
|
||||||
interface ManageBannerProps {
|
interface ManageBannerProps {
|
||||||
oidc?: NonNullable<HeadplaneConfig['oidc']>;
|
oidc?: { issuer: string };
|
||||||
isDisabled?: boolean;
|
isDisabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
|
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
|
||||||
return (
|
return (
|
||||||
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
<Card className="mb-8 w-full max-w-full p-0" variant="flat">
|
||||||
<div className="flex flex-col md:flex-row">
|
<div className="flex flex-col md:flex-row">
|
||||||
<div className="w-full p-4 border-b md:border-b-0 border-headplane-100 dark:border-headplane-800">
|
<div className="w-full p-4 border-b md:border-b-0 border-headplane-100 dark:border-headplane-800">
|
||||||
{oidc ? (
|
{oidc ? (
|
||||||
@@ -26,14 +25,14 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
|
|||||||
{oidc ? (
|
{oidc ? (
|
||||||
<>
|
<>
|
||||||
Users are managed through your{' '}
|
Users are managed through your{' '}
|
||||||
<Link to={oidc.issuer} name="OIDC Provider">
|
<Link name="OIDC Provider" to={oidc.issuer}>
|
||||||
OpenID Connect provider
|
OpenID Connect provider
|
||||||
</Link>
|
</Link>
|
||||||
{'. '}
|
{'. '}
|
||||||
Groups and user information do not automatically sync.{' '}
|
Groups and user information do not automatically sync.{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://headscale.net/stable/ref/oidc"
|
|
||||||
name="Headscale OIDC Documentation"
|
name="Headscale OIDC Documentation"
|
||||||
|
to="https://headscale.net/stable/ref/oidc"
|
||||||
>
|
>
|
||||||
Learn more
|
Learn more
|
||||||
</Link>
|
</Link>
|
||||||
@@ -43,8 +42,8 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
|
|||||||
Users are not managed externally. Using OpenID Connect can
|
Users are not managed externally. Using OpenID Connect can
|
||||||
create a better experience when using Headscale.{' '}
|
create a better experience when using Headscale.{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://headscale.net/stable/ref/oidc"
|
|
||||||
name="Headscale OIDC Documentation"
|
name="Headscale OIDC Documentation"
|
||||||
|
to="https://headscale.net/stable/ref/oidc"
|
||||||
>
|
>
|
||||||
Learn more
|
Learn more
|
||||||
</Link>
|
</Link>
|
||||||
@@ -61,7 +60,7 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
|
|||||||
: 'You can add, remove, and rename users here.'}
|
: 'You can add, remove, and rename users here.'}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 mt-4">
|
<div className="flex items-center gap-2 mt-4">
|
||||||
<CreateUser isOidc={oidc !== undefined} isDisabled={isDisabled} />
|
<CreateUser isDisabled={isDisabled} isOidc={oidc !== undefined} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,74 +1,102 @@
|
|||||||
import { Ellipsis } from 'lucide-react';
|
import { Ellipsis } from "lucide-react";
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import Menu from '~/components/Menu';
|
|
||||||
import type { Machine, User } from '~/types';
|
import Menu from "~/components/Menu";
|
||||||
import cn from '~/utils/cn';
|
import type { Machine, User } from "~/types";
|
||||||
import Delete from '../dialogs/delete-user';
|
import cn from "~/utils/cn";
|
||||||
import Reassign from '../dialogs/reassign-user';
|
|
||||||
import Rename from '../dialogs/rename-user';
|
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 {
|
interface MenuProps {
|
||||||
user: User & {
|
user: User & {
|
||||||
headplaneRole: string;
|
headplaneRole: string;
|
||||||
machines: Machine[];
|
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) {
|
export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProps) {
|
||||||
const [modal, setModal] = useState<Modal>(null);
|
const [modal, setModal] = useState<Modal>(null);
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{modal === 'rename' && (
|
|
||||||
<Rename
|
|
||||||
user={user}
|
|
||||||
isOpen={modal === 'rename'}
|
|
||||||
setIsOpen={(isOpen) => {
|
|
||||||
if (!isOpen) setModal(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{modal === 'delete' && (
|
|
||||||
<Delete
|
|
||||||
user={user}
|
|
||||||
isOpen={modal === 'delete'}
|
|
||||||
setIsOpen={(isOpen) => {
|
|
||||||
if (!isOpen) setModal(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{modal === 'reassign' && (
|
|
||||||
<Reassign
|
|
||||||
user={user}
|
|
||||||
isOpen={modal === 'reassign'}
|
|
||||||
setIsOpen={(isOpen) => {
|
|
||||||
if (!isOpen) setModal(null);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : ['reassign']}>
|
const disabledKeys: string[] = [];
|
||||||
<Menu.IconButton
|
if (user.provider === "oidc") {
|
||||||
label="Machine Options"
|
disabledKeys.push("rename");
|
||||||
className={cn(
|
} else {
|
||||||
'py-0.5 w-10 bg-transparent border-transparent',
|
disabledKeys.push("reassign", "link");
|
||||||
'border group-hover:border-headplane-200',
|
}
|
||||||
'dark:group-hover:border-headplane-700',
|
|
||||||
)}
|
// Filter linkable users: unclaimed, or the one currently linked to this user
|
||||||
>
|
const linkableUsers = headscaleUsers.filter((u) => !u.claimed || u.id === currentLink);
|
||||||
<Ellipsis className="h-5" />
|
|
||||||
</Menu.IconButton>
|
return (
|
||||||
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
|
<>
|
||||||
<Menu.Section>
|
{modal === "rename" && (
|
||||||
<Menu.Item key="rename">Rename user</Menu.Item>
|
<Rename
|
||||||
<Menu.Item key="reassign">Change role</Menu.Item>
|
isOpen={modal === "rename"}
|
||||||
<Menu.Item key="delete" textValue="Delete">
|
setIsOpen={(isOpen) => {
|
||||||
<p className="text-red-500 dark:text-red-400">Delete</p>
|
if (!isOpen) setModal(null);
|
||||||
</Menu.Item>
|
}}
|
||||||
</Menu.Section>
|
user={user}
|
||||||
</Menu.Panel>
|
/>
|
||||||
</Menu>
|
)}
|
||||||
</>
|
{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,95 +1,97 @@
|
|||||||
import { CircleUser } from 'lucide-react';
|
import { CircleUser } from "lucide-react";
|
||||||
import StatusCircle from '~/components/StatusCircle';
|
|
||||||
import { Machine, User } from '~/types';
|
import StatusCircle from "~/components/StatusCircle";
|
||||||
import cn from '~/utils/cn';
|
import { Machine, User } from "~/types";
|
||||||
import MenuOptions from './menu';
|
import cn from "~/utils/cn";
|
||||||
|
|
||||||
|
import MenuOptions from "./menu";
|
||||||
|
|
||||||
interface UserRowProps {
|
interface UserRowProps {
|
||||||
role: string;
|
role: string;
|
||||||
user: User & { machines: Machine[] };
|
user: User & { machines: Machine[] };
|
||||||
|
headscaleUsers: { id: string; name: string; claimed: boolean }[];
|
||||||
|
currentLink?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserRow({ user, role }: UserRowProps) {
|
export default function UserRow({ user, role, headscaleUsers, currentLink }: UserRowProps) {
|
||||||
const isOnline = user.machines.some((machine) => machine.online);
|
const isOnline = user.machines.some((machine) => machine.online);
|
||||||
const lastSeen = user.machines.reduce(
|
const lastSeen = user.machines.reduce(
|
||||||
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
|
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={user.id}>
|
||||||
key={user.id}
|
<td className="py-2 pl-0.5">
|
||||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
<div className="flex items-center">
|
||||||
>
|
{user.profilePicUrl ? (
|
||||||
<td className="pl-0.5 py-2">
|
<img
|
||||||
<div className="flex items-center">
|
alt={user.name || user.displayName}
|
||||||
{user.profilePicUrl ? (
|
className="h-10 w-10 rounded-full"
|
||||||
<img
|
src={user.profilePicUrl}
|
||||||
src={user.profilePicUrl}
|
/>
|
||||||
alt={user.name || user.displayName}
|
) : (
|
||||||
className="w-10 h-10 rounded-full"
|
<CircleUser className="h-10 w-10" />
|
||||||
/>
|
)}
|
||||||
) : (
|
<div className="ml-4">
|
||||||
<CircleUser className="w-10 h-10" />
|
<p className={cn("font-semibold leading-snug")}>{user.name || user.displayName}</p>
|
||||||
)}
|
<p className="text-sm opacity-50">{user.email}</p>
|
||||||
<div className="ml-4">
|
</div>
|
||||||
<p className={cn('font-semibold leading-snug')}>{user.name || user.displayName}</p>
|
</div>
|
||||||
<p className="text-sm opacity-50">{user.email}</p>
|
</td>
|
||||||
</div>
|
<td className="py-2 pl-0.5">
|
||||||
</div>
|
<p>{mapRoleToName(role)}</p>
|
||||||
</td>
|
</td>
|
||||||
<td className="pl-0.5 py-2">
|
<td className="py-2 pl-0.5">
|
||||||
<p>{mapRoleToName(role)}</p>
|
<p className="text-headplane-600 dark:text-headplane-300 text-sm" suppressHydrationWarning>
|
||||||
</td>
|
{new Date(user.createdAt).toLocaleDateString()}
|
||||||
<td className="pl-0.5 py-2">
|
</p>
|
||||||
<p
|
</td>
|
||||||
suppressHydrationWarning
|
<td className="py-2 pl-0.5">
|
||||||
className="text-sm text-headplane-600 dark:text-headplane-300"
|
<span
|
||||||
>
|
className={cn(
|
||||||
{new Date(user.createdAt).toLocaleDateString()}
|
"flex items-center gap-x-1 text-sm",
|
||||||
</p>
|
"text-headplane-600 dark:text-headplane-300",
|
||||||
</td>
|
)}
|
||||||
<td className="pl-0.5 py-2">
|
>
|
||||||
<span
|
<StatusCircle className="h-4 w-4" isOnline={isOnline} />
|
||||||
className={cn(
|
<p suppressHydrationWarning>
|
||||||
'flex items-center gap-x-1 text-sm',
|
{isOnline ? "Connected" : new Date(lastSeen).toLocaleString()}
|
||||||
'text-headplane-600 dark:text-headplane-300',
|
</p>
|
||||||
)}
|
</span>
|
||||||
>
|
</td>
|
||||||
<StatusCircle isOnline={isOnline} className="w-4 h-4" />
|
<td className="py-2 pr-0.5">
|
||||||
<p suppressHydrationWarning>
|
<MenuOptions
|
||||||
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
|
currentLink={currentLink}
|
||||||
</p>
|
headscaleUsers={headscaleUsers}
|
||||||
</span>
|
user={{ ...user, headplaneRole: role }}
|
||||||
</td>
|
/>
|
||||||
<td className="py-2 pr-0.5">
|
</td>
|
||||||
<MenuOptions user={{ ...user, headplaneRole: role }} />
|
</tr>
|
||||||
</td>
|
);
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapRoleToName(role: string) {
|
function mapRoleToName(role: string) {
|
||||||
switch (role) {
|
switch (role) {
|
||||||
case 'no-oidc':
|
case "no-oidc":
|
||||||
return <p className="opacity-50">Unmanaged</p>;
|
return <p className="opacity-50">Unmanaged</p>;
|
||||||
case 'invalid-oidc':
|
case "invalid-oidc":
|
||||||
return <p className="opacity-50">Invalid</p>;
|
return <p className="opacity-50">Invalid</p>;
|
||||||
case 'no-role':
|
case "no-role":
|
||||||
return <p className="opacity-50">Unregistered</p>;
|
return <p className="opacity-50">Unregistered</p>;
|
||||||
case 'owner':
|
case "owner":
|
||||||
return 'Owner';
|
return "Owner";
|
||||||
case 'admin':
|
case "admin":
|
||||||
return 'Admin';
|
return "Admin";
|
||||||
case 'network_admin':
|
case "network_admin":
|
||||||
return 'Network Admin';
|
return "Network Admin";
|
||||||
case 'it_admin':
|
case "it_admin":
|
||||||
return 'IT Admin';
|
return "IT Admin";
|
||||||
case 'auditor':
|
case "auditor":
|
||||||
return 'Auditor';
|
return "Auditor";
|
||||||
case 'member':
|
case "member":
|
||||||
return 'Member';
|
return <p className="opacity-50">No Access</p>;
|
||||||
default:
|
default:
|
||||||
return 'Unknown';
|
return "Unknown";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,15 +24,14 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
|
|||||||
</>
|
</>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<input type="hidden" name="action_id" value="create_user" />
|
<input name="action_id" type="hidden" value="create_user" />
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<Input
|
<Input
|
||||||
isRequired
|
isRequired
|
||||||
name="username"
|
|
||||||
type="text"
|
|
||||||
label="Username"
|
label="Username"
|
||||||
|
name="username"
|
||||||
placeholder="my-new-user"
|
placeholder="my-new-user"
|
||||||
validationBehavior="native"
|
type="text"
|
||||||
validate={(value) => {
|
validate={(value) => {
|
||||||
if (value.trim().length === 0) {
|
if (value.trim().length === 0) {
|
||||||
return 'Username is required';
|
return 'Username is required';
|
||||||
@@ -44,19 +43,20 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
}}
|
}}
|
||||||
/>
|
|
||||||
<Input
|
|
||||||
name="display_name"
|
|
||||||
type="text"
|
|
||||||
label="Display Name"
|
|
||||||
placeholder="John Doe"
|
|
||||||
validationBehavior="native"
|
validationBehavior="native"
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
name="email"
|
label="Display Name"
|
||||||
type="email"
|
name="display_name"
|
||||||
|
placeholder="John Doe"
|
||||||
|
type="text"
|
||||||
|
validationBehavior="native"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
label="Email"
|
label="Email"
|
||||||
|
name="email"
|
||||||
placeholder="name@example.com"
|
placeholder="name@example.com"
|
||||||
|
type="email"
|
||||||
validationBehavior="native"
|
validationBehavior="native"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
|
|||||||
)}
|
)}
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
)}
|
)}
|
||||||
<input type="hidden" name="action_id" value="delete_user" />
|
<input name="action_id" type="hidden" value="delete_user" />
|
||||||
<input type="hidden" name="user_id" value={user.id} />
|
<input name="user_id" type="hidden" value={user.id} />
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,14 +21,16 @@ export default function ReassignUser({
|
|||||||
<Dialog.Panel
|
<Dialog.Panel
|
||||||
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
|
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
|
||||||
>
|
>
|
||||||
<Dialog.Title>Change role for {user.name || user.displayName}?</Dialog.Title>
|
<Dialog.Title>
|
||||||
|
Change role for {user.name || user.displayName}?
|
||||||
|
</Dialog.Title>
|
||||||
<Dialog.Text className="mb-6">
|
<Dialog.Text className="mb-6">
|
||||||
Most roles are carried straight from Tailscale. However, keep in mind
|
Most roles are carried straight from Tailscale. However, keep in mind
|
||||||
that I have not fully implemented permissions yet and some things may
|
that I have not fully implemented permissions yet and some things may
|
||||||
be accessible to everyone. The only fully completed role is Member.{' '}
|
be accessible to everyone. The only fully completed role is Member.{' '}
|
||||||
<Link
|
<Link
|
||||||
to="https://tailscale.com/kb/1138/user-roles"
|
|
||||||
name="Tailscale User Roles documentation"
|
name="Tailscale User Roles documentation"
|
||||||
|
to="https://tailscale.com/kb/1138/user-roles"
|
||||||
>
|
>
|
||||||
Learn More
|
Learn More
|
||||||
</Link>
|
</Link>
|
||||||
@@ -37,21 +39,21 @@ export default function ReassignUser({
|
|||||||
<Notice>The Tailnet owner cannot be reassigned.</Notice>
|
<Notice>The Tailnet owner cannot be reassigned.</Notice>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<input type="hidden" name="action_id" value="reassign_user" />
|
<input name="action_id" type="hidden" value="reassign_user" />
|
||||||
<input type="hidden" name="user_id" value={user.id} />
|
<input name="user_id" type="hidden" value={user.id} />
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
isRequired
|
|
||||||
name="new_role"
|
|
||||||
label="Role"
|
|
||||||
className="gap-4"
|
className="gap-4"
|
||||||
defaultValue={user.headplaneRole}
|
defaultValue={user.headplaneRole}
|
||||||
|
isRequired
|
||||||
|
label="Role"
|
||||||
|
name="new_role"
|
||||||
>
|
>
|
||||||
{Object.keys(Roles)
|
{Object.keys(Roles)
|
||||||
.filter((role) => role !== 'owner')
|
.filter((role) => role !== 'owner')
|
||||||
.map((role) => {
|
.map((role) => {
|
||||||
const { name, desc } = mapRoleToName(role);
|
const { name, desc } = mapRoleToName(role);
|
||||||
return (
|
return (
|
||||||
<RadioGroup.Radio key={role} value={role} label={name}>
|
<RadioGroup.Radio key={role} label={name} value={role}>
|
||||||
<div className="block">
|
<div className="block">
|
||||||
<p className="font-bold">{name}</p>
|
<p className="font-bold">{name}</p>
|
||||||
<p className="opacity-70">{desc}</p>
|
<p className="opacity-70">{desc}</p>
|
||||||
|
|||||||
@@ -15,18 +15,18 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
|
|||||||
<Dialog.Panel>
|
<Dialog.Panel>
|
||||||
<Dialog.Title>Rename {user.name || user.displayName}?</Dialog.Title>
|
<Dialog.Title>Rename {user.name || user.displayName}?</Dialog.Title>
|
||||||
<Dialog.Text className="mb-6">
|
<Dialog.Text className="mb-6">
|
||||||
Enter a new username for {user.name || user.displayName}. Changing a username will not
|
Enter a new username for {user.name || user.displayName}. Changing a
|
||||||
update any ACL policies that may refer to this user by their old
|
username will not update any ACL policies that may refer to this user
|
||||||
username.
|
by their old username.
|
||||||
</Dialog.Text>
|
</Dialog.Text>
|
||||||
<input type="hidden" name="action_id" value="rename_user" />
|
<input name="action_id" type="hidden" value="rename_user" />
|
||||||
<input type="hidden" name="user_id" value={user.id} />
|
<input name="user_id" type="hidden" value={user.id} />
|
||||||
<Input
|
<Input
|
||||||
isRequired
|
|
||||||
name="new_name"
|
|
||||||
label="Username"
|
|
||||||
placeholder="my-new-name"
|
|
||||||
defaultValue={user.name}
|
defaultValue={user.name}
|
||||||
|
isRequired
|
||||||
|
label="Username"
|
||||||
|
name="new_name"
|
||||||
|
placeholder="my-new-name"
|
||||||
/>
|
/>
|
||||||
</Dialog.Panel>
|
</Dialog.Panel>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
@@ -1,23 +1,52 @@
|
|||||||
import { eq } from 'drizzle-orm';
|
import { eq } from "drizzle-orm";
|
||||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
import { redirect } from "react-router";
|
||||||
import { LoadContext } from '~/server';
|
|
||||||
import { users } from '~/server/db/schema';
|
|
||||||
|
|
||||||
export async function loader({
|
import { users } from "~/server/db/schema";
|
||||||
request,
|
|
||||||
context,
|
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
|
||||||
try {
|
|
||||||
const { user } = await context.sessions.auth(request);
|
|
||||||
await context.db
|
|
||||||
.update(users)
|
|
||||||
.set({
|
|
||||||
onboarded: true,
|
|
||||||
})
|
|
||||||
.where(eq(users.sub, user.subject));
|
|
||||||
|
|
||||||
return redirect('/machines');
|
import type { Route } from "./+types/onboarding-skip";
|
||||||
} catch {
|
|
||||||
return redirect('/login');
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
}
|
try {
|
||||||
|
const principal = await context.auth.require(request);
|
||||||
|
if (principal.kind !== "oidc") {
|
||||||
|
return redirect("/machines");
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+368
-314
@@ -1,333 +1,387 @@
|
|||||||
import { Icon } from '@iconify/react';
|
import { Icon } from "@iconify/react";
|
||||||
import { ArrowRight } from 'lucide-react';
|
import { ArrowRight } from "lucide-react";
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from "react";
|
||||||
import { LoaderFunctionArgs, NavLink, useLoaderData } from 'react-router';
|
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 { LoadContext } from '~/server';
|
|
||||||
import { Machine } from '~/types';
|
|
||||||
import cn from '~/utils/cn';
|
|
||||||
import { useLiveData } from '~/utils/live-data';
|
|
||||||
import log from '~/utils/log';
|
|
||||||
import toast from '~/utils/toast';
|
|
||||||
|
|
||||||
export async function loader({
|
import Button from "~/components/Button";
|
||||||
request,
|
import Card from "~/components/Card";
|
||||||
context,
|
import Link from "~/components/Link";
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
import Options from "~/components/Options";
|
||||||
const session = await context.sessions.auth(request);
|
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";
|
||||||
|
|
||||||
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
|
import type { Route } from "./+types/onboarding";
|
||||||
// We need to convert this to a known value to return it to the client so we can
|
|
||||||
// automatically tab to the correct download button.
|
|
||||||
const userAgent = request.headers.get('user-agent');
|
|
||||||
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
|
|
||||||
let osValue = 'linux';
|
|
||||||
switch (os?.[0]) {
|
|
||||||
case 'Windows':
|
|
||||||
osValue = 'windows';
|
|
||||||
break;
|
|
||||||
case 'Mac OS X':
|
|
||||||
osValue = 'macos';
|
|
||||||
break;
|
|
||||||
|
|
||||||
case 'iPhone':
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
case 'iPad':
|
const principal = await context.auth.require(request);
|
||||||
osValue = 'ios';
|
if (principal.kind !== "oidc") {
|
||||||
break;
|
throw new Error("Onboarding is only available for OIDC users.");
|
||||||
|
}
|
||||||
|
|
||||||
case 'Android':
|
const userAgent = request.headers.get("user-agent");
|
||||||
osValue = 'android';
|
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
|
||||||
break;
|
let osValue = "linux";
|
||||||
|
switch (os?.[0]) {
|
||||||
|
case "Windows":
|
||||||
|
osValue = "windows";
|
||||||
|
break;
|
||||||
|
case "Mac OS X":
|
||||||
|
osValue = "macos";
|
||||||
|
break;
|
||||||
|
|
||||||
default:
|
case "iPhone":
|
||||||
osValue = 'linux';
|
case "iPad":
|
||||||
break;
|
osValue = "ios";
|
||||||
}
|
break;
|
||||||
|
|
||||||
let firstMachine: Machine | undefined;
|
case "Android":
|
||||||
try {
|
osValue = "android";
|
||||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
break;
|
||||||
'v1/node',
|
|
||||||
session.api_key,
|
|
||||||
);
|
|
||||||
|
|
||||||
const node = nodes.find((n) => {
|
default:
|
||||||
if (n.user.provider !== 'oidc') {
|
osValue = "linux";
|
||||||
return false;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// For some reason, headscale makes providerID a url where the
|
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||||
// last component is the subject, so we need to strip that out
|
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||||
const subject = n.user.providerId?.split('/').pop();
|
|
||||||
if (!subject) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subject !== session.user.subject) {
|
const hsUserId = principal.user.headscaleUserId;
|
||||||
return false;
|
let firstMachine: Machine | undefined;
|
||||||
}
|
let needsUserLink = false;
|
||||||
|
let linkedUserName: string | undefined;
|
||||||
|
let headscaleUsers: { id: string; name: string }[] = [];
|
||||||
|
|
||||||
return true;
|
try {
|
||||||
});
|
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||||
|
|
||||||
firstMachine = node;
|
if (hsUserId) {
|
||||||
} catch (e) {
|
const hsUser = apiUsers.find((u) => u.id === hsUserId);
|
||||||
// If we cannot lookup nodes, we cannot proceed
|
linkedUserName = hsUser ? getUserDisplayName(hsUser) : undefined;
|
||||||
log.debug('api', 'Failed to lookup nodes %o', e);
|
firstMachine = nodes.find((n) => n.user?.id === hsUserId);
|
||||||
}
|
} else {
|
||||||
|
const matched = findHeadscaleUserBySubject(
|
||||||
|
apiUsers,
|
||||||
|
principal.user.subject,
|
||||||
|
principal.profile.email,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
if (matched) {
|
||||||
user: session.user,
|
await context.auth.linkHeadscaleUser(principal.user.id, matched.id);
|
||||||
osValue,
|
linkedUserName = getUserDisplayName(matched);
|
||||||
firstMachine,
|
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: {
|
||||||
|
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() {
|
export default function Page({
|
||||||
const { user, osValue, firstMachine } = useLoaderData<typeof loader>();
|
loaderData: { user, osValue, firstMachine, needsUserLink, linkedUserName, headscaleUsers },
|
||||||
const { pause, resume } = useLiveData();
|
}: Route.ComponentProps) {
|
||||||
useEffect(() => {
|
const { pause, resume } = useLiveData();
|
||||||
if (firstMachine) {
|
useEffect(() => {
|
||||||
pause();
|
if (firstMachine) {
|
||||||
} else {
|
pause();
|
||||||
resume();
|
} else {
|
||||||
}
|
resume();
|
||||||
}, [firstMachine]);
|
}
|
||||||
|
}, [firstMachine]);
|
||||||
|
|
||||||
const subject = user.email ? (
|
const subject = user.email ? (
|
||||||
<>
|
<>
|
||||||
as <strong>{user.email}</strong>
|
as <strong>{user.email}</strong>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
'with your OIDC provider'
|
"with your OIDC provider"
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed w-full h-screen flex items-center px-4">
|
<div className="fixed flex h-screen w-full items-center px-4">
|
||||||
<div className="w-fit mx-auto grid grid-cols-1 md:grid-cols-2 gap-4 mb-24">
|
<div className="mx-auto mb-24 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
|
||||||
<Card className="max-w-lg" variant="flat">
|
{needsUserLink ? (
|
||||||
<Card.Title className="mb-8">
|
<Card className="col-span-2 mx-auto max-w-lg" variant="flat">
|
||||||
Welcome!
|
<Card.Title className="mb-4">Link your Headscale account</Card.Title>
|
||||||
<br />
|
<Card.Text className="mb-4">
|
||||||
Let's get set up
|
Headplane couldn't automatically match your SSO identity to a Headscale user.
|
||||||
</Card.Title>
|
{headscaleUsers.length > 0
|
||||||
<Card.Text>
|
? " Select which Headscale user you are, or skip to continue without linking."
|
||||||
Install Tailscale and sign in {subject}. Once you sign in on a
|
: " All Headscale users are already linked. You can skip this step and ask an admin to link your account later."}
|
||||||
device, it will be automatically added to your Headscale network.
|
</Card.Text>
|
||||||
</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
|
<Options className="my-4" defaultSelectedKey={osValue} label="Download Selector">
|
||||||
className="my-4"
|
<Options.Item
|
||||||
defaultSelectedKey={osValue}
|
key="linux"
|
||||||
label="Download Selector"
|
title={
|
||||||
>
|
<div className="flex items-center gap-1">
|
||||||
<Options.Item
|
<Icon className="ml-1 w-4" icon="ion:terminal" />
|
||||||
key="linux"
|
<span>Linux</span>
|
||||||
title={
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
}
|
||||||
<Icon className="ml-1 w-4" icon="ion:terminal" />
|
>
|
||||||
<span>Linux</span>
|
<Button
|
||||||
</div>
|
className="text-md flex font-mono"
|
||||||
}
|
onPress={async () => {
|
||||||
>
|
await navigator.clipboard.writeText(
|
||||||
<Button
|
"curl -fsSL https://tailscale.com/install.sh | sh",
|
||||||
className="flex text-md font-mono"
|
);
|
||||||
onPress={async () => {
|
|
||||||
await navigator.clipboard.writeText(
|
|
||||||
'curl -fsSL https://tailscale.com/install.sh | sh',
|
|
||||||
);
|
|
||||||
|
|
||||||
toast('Copied to clipboard');
|
toast("Copied to clipboard");
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
curl -fsSL https://tailscale.com/install.sh | sh
|
curl -fsSL https://tailscale.com/install.sh | sh
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs mt-1 text-headplane-600 dark:text-headplane-300 text-center">
|
<p className="text-headplane-600 dark:text-headplane-300 mt-1 text-center text-xs">
|
||||||
Click this button to copy the command.{' '}
|
Click this button to copy the command.{" "}
|
||||||
<Link
|
<Link
|
||||||
name="Linux installation script"
|
name="Linux installation script"
|
||||||
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
|
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
|
||||||
>
|
>
|
||||||
View script source
|
View script source
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</Options.Item>
|
</Options.Item>
|
||||||
<Options.Item
|
<Options.Item
|
||||||
key="windows"
|
key="windows"
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
|
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
|
||||||
<span>Windows</span>
|
<span>Windows</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
aria-label="Download for Windows"
|
aria-label="Download for Windows"
|
||||||
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
>
|
>
|
||||||
<Button className="my-4 w-full" variant="heavy">
|
<Button className="my-4 w-full" variant="heavy">
|
||||||
Download for Windows
|
Download for Windows
|
||||||
</Button>
|
</Button>
|
||||||
</a>
|
</a>
|
||||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||||
Requires Windows 10 or later.
|
Requires Windows 10 or later.
|
||||||
</p>
|
</p>
|
||||||
</Options.Item>
|
</Options.Item>
|
||||||
<Options.Item
|
<Options.Item
|
||||||
key="macos"
|
key="macos"
|
||||||
title={
|
title={
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Icon
|
<Icon className="ml-1 w-4" icon="streamline-logos:mac-finder-logo-solid" />
|
||||||
className="ml-1 w-4"
|
<span>macOS</span>
|
||||||
icon="streamline-logos:mac-finder-logo-solid"
|
</div>
|
||||||
/>
|
}
|
||||||
<span>macOS</span>
|
>
|
||||||
</div>
|
<a
|
||||||
}
|
aria-label="Download for macOS"
|
||||||
>
|
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
||||||
<a
|
rel="noreferrer"
|
||||||
aria-label="Download for macOS"
|
target="_blank"
|
||||||
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
>
|
||||||
rel="noreferrer"
|
<Button className="my-4 w-full" variant="heavy">
|
||||||
target="_blank"
|
Download for macOS
|
||||||
>
|
</Button>
|
||||||
<Button className="my-4 w-full" variant="heavy">
|
</a>
|
||||||
Download for macOS
|
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||||
</Button>
|
Requires macOS Big Sur 11.0 or later.
|
||||||
</a>
|
<br />
|
||||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
You can also download Tailscale on the{" "}
|
||||||
Requires macOS Big Sur 11.0 or later.
|
<Link
|
||||||
<br />
|
name="macOS App Store"
|
||||||
You can also download Tailscale on the{' '}
|
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
|
||||||
<Link
|
>
|
||||||
name="macOS App Store"
|
macOS App Store
|
||||||
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
|
</Link>
|
||||||
>
|
{"."}
|
||||||
macOS App Store
|
</p>
|
||||||
</Link>
|
</Options.Item>
|
||||||
{'.'}
|
<Options.Item
|
||||||
</p>
|
key="ios"
|
||||||
</Options.Item>
|
title={
|
||||||
<Options.Item
|
<div className="flex items-center gap-1">
|
||||||
key="ios"
|
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
|
||||||
title={
|
<span>iOS</span>
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
<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"
|
||||||
<a
|
rel="noreferrer"
|
||||||
aria-label="Download for iOS"
|
target="_blank"
|
||||||
href="https://apps.apple.com/us/app/tailscale/id1470499037"
|
>
|
||||||
rel="noreferrer"
|
<Button className="my-4 w-full" variant="heavy">
|
||||||
target="_blank"
|
Download for iOS
|
||||||
>
|
</Button>
|
||||||
<Button className="my-4 w-full" variant="heavy">
|
</a>
|
||||||
Download for iOS
|
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||||
</Button>
|
Requires iOS 15 or later.
|
||||||
</a>
|
</p>
|
||||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
</Options.Item>
|
||||||
Requires iOS 15 or later.
|
<Options.Item
|
||||||
</p>
|
key="android"
|
||||||
</Options.Item>
|
title={
|
||||||
<Options.Item
|
<div className="flex items-center gap-1">
|
||||||
key="android"
|
<Icon className="ml-1 w-4" icon="material-symbols:android" />
|
||||||
title={
|
<span>Android</span>
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
<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"
|
||||||
<a
|
rel="noreferrer"
|
||||||
aria-label="Download for Android"
|
target="_blank"
|
||||||
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
|
>
|
||||||
rel="noreferrer"
|
<Button className="my-4 w-full" variant="heavy">
|
||||||
target="_blank"
|
Download for Android
|
||||||
>
|
</Button>
|
||||||
<Button className="my-4 w-full" variant="heavy">
|
</a>
|
||||||
Download for Android
|
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||||
</Button>
|
Requires Android 8 or later.
|
||||||
</a>
|
</p>
|
||||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
</Options.Item>
|
||||||
Requires Android 8 or later.
|
</Options>
|
||||||
</p>
|
</Card>
|
||||||
</Options.Item>
|
<Card variant="flat">
|
||||||
</Options>
|
{firstMachine ? (
|
||||||
</Card>
|
<div className="flex h-full flex-col justify-between">
|
||||||
<Card variant="flat">
|
<Card.Title className="mb-8">
|
||||||
{firstMachine ? (
|
Success!
|
||||||
<div className="flex flex-col justify-between h-full">
|
<br />
|
||||||
<Card.Title className="mb-8">
|
We found your first device
|
||||||
Success!
|
</Card.Title>
|
||||||
<br />
|
<div className="border-headplane-100 dark:border-headplane-800 rounded-xl border p-4">
|
||||||
We found your first device
|
<div className="flex items-start gap-4">
|
||||||
</Card.Title>
|
<StatusCircle className="mt-3 size-6" isOnline={firstMachine.online} />
|
||||||
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4">
|
<div>
|
||||||
<div className="flex items-start gap-4">
|
<p className="leading-snug font-semibold">{firstMachine.givenName}</p>
|
||||||
<StatusCircle
|
<p className="font-mono text-sm opacity-50">{firstMachine.name}</p>
|
||||||
className="size-6 mt-3"
|
<div className="mt-6">
|
||||||
isOnline={firstMachine.online}
|
<p className="text-sm font-semibold">IP Addresses</p>
|
||||||
/>
|
{firstMachine.ipAddresses.map((ip) => (
|
||||||
<div>
|
<p className="font-mono text-xs opacity-50" key={ip}>
|
||||||
<p className="font-semibold leading-snug">
|
{ip}
|
||||||
{firstMachine.givenName}
|
</p>
|
||||||
</p>
|
))}
|
||||||
<p className="text-sm font-mono opacity-50">
|
</div>
|
||||||
{firstMachine.name}
|
</div>
|
||||||
</p>
|
</div>
|
||||||
<div className="mt-6">
|
</div>
|
||||||
<p className="text-sm font-semibold">IP Addresses</p>
|
<NavLink to="/onboarding/skip">
|
||||||
{firstMachine.ipAddresses.map((ip) => (
|
<Button className="w-full" variant="heavy">
|
||||||
<p className="text-xs font-mono opacity-50" key={ip}>
|
Continue
|
||||||
{ip}
|
</Button>
|
||||||
</p>
|
</NavLink>
|
||||||
))}
|
</div>
|
||||||
</div>
|
) : (
|
||||||
</div>
|
<div className="flex h-full flex-col items-center justify-center gap-4">
|
||||||
</div>
|
<span className="relative flex size-4">
|
||||||
</div>
|
<span
|
||||||
<NavLink to="/onboarding/skip">
|
className={cn(
|
||||||
<Button className="w-full" variant="heavy">
|
"absolute inline-flex h-full w-full",
|
||||||
Continue
|
"rounded-full opacity-75 animate-ping",
|
||||||
</Button>
|
"bg-headplane-500",
|
||||||
</NavLink>
|
)}
|
||||||
</div>
|
/>
|
||||||
) : (
|
<span
|
||||||
<div className="flex flex-col items-center justify-center gap-4 h-full">
|
className={cn("relative inline-flex size-4 rounded-full", "bg-headplane-400")}
|
||||||
<span className="relative flex size-4">
|
/>
|
||||||
<span
|
</span>
|
||||||
className={cn(
|
<p className="font-lg">Waiting for your first device...</p>
|
||||||
'absolute inline-flex h-full w-full',
|
</div>
|
||||||
'rounded-full opacity-75 animate-ping',
|
)}
|
||||||
'bg-headplane-500',
|
</Card>
|
||||||
)}
|
<NavLink className="col-span-2 mx-auto w-max" to="/onboarding/skip">
|
||||||
/>
|
<Button className="flex items-center gap-1">
|
||||||
<span
|
I already know what I'm doing
|
||||||
className={cn(
|
<ArrowRight className="p-1" />
|
||||||
'relative inline-flex size-4 rounded-full',
|
</Button>
|
||||||
'bg-headplane-400',
|
</NavLink>
|
||||||
)}
|
</div>
|
||||||
/>
|
</div>
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+151
-122
@@ -1,138 +1,167 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { createHash } from "node:crypto";
|
||||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
|
||||||
import { useLoaderData } from 'react-router';
|
import { eq } from "drizzle-orm";
|
||||||
import type { LoadContext } from '~/server';
|
import { useEffect, useState } from "react";
|
||||||
import { Capabilities } from '~/server/web/roles';
|
|
||||||
import { Machine, User } from '~/types';
|
import { users as usersTable } from "~/server/db/schema";
|
||||||
import cn from '~/utils/cn';
|
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||||
import ManageBanner from './components/manage-banner';
|
import { Capabilities } from "~/server/web/roles";
|
||||||
import UserRow from './components/user-row';
|
import type { Machine, User } from "~/types";
|
||||||
import { userAction } from './user-actions';
|
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 {
|
interface UserMachine extends User {
|
||||||
machines: Machine[];
|
machines: Machine[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loader({
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
request,
|
const principal = await context.auth.require(request);
|
||||||
context,
|
const check = await context.auth.can(principal, Capabilities.read_users);
|
||||||
}: LoaderFunctionArgs<LoadContext>) {
|
if (!check) {
|
||||||
const session = await context.sessions.auth(request);
|
// Not authorized to view this page
|
||||||
const check = await context.sessions.check(request, Capabilities.read_users);
|
throw new Error(
|
||||||
if (!check) {
|
"You do not have permission to view this page. Please contact your administrator.",
|
||||||
// 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(
|
const writablePermission = await context.auth.can(principal, Capabilities.write_users);
|
||||||
request,
|
|
||||||
Capabilities.write_users,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [machines, apiUsers] = await Promise.all([
|
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||||
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
|
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||||
context.client.get<{ users: User[] }>('v1/user', session.api_key),
|
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||||
]);
|
|
||||||
|
|
||||||
const users = apiUsers.users.map((user) => ({
|
const users = apiUsers.map((user) => ({
|
||||||
...user,
|
...user,
|
||||||
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
|
machines: nodes.filter((node) => node.user?.id === user.id),
|
||||||
}));
|
profilePicUrl:
|
||||||
|
context.config.oidc?.profile_picture_source === "gravatar"
|
||||||
|
? (() => {
|
||||||
|
if (!user.email) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
const roles = await Promise.all(
|
const emailHash = user.email.trim().toLowerCase();
|
||||||
users
|
const hash = createHash("sha256").update(emailHash).digest("hex");
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
|
||||||
.map(async (user) => {
|
})()
|
||||||
if (user.provider !== 'oidc') {
|
: user.profilePicUrl,
|
||||||
return 'no-oidc';
|
}));
|
||||||
}
|
|
||||||
|
|
||||||
if (user.provider === 'oidc' && user.providerId) {
|
const roles = await Promise.all(
|
||||||
// For some reason, headscale makes providerID a url where the
|
users
|
||||||
// last component is the subject, so we need to strip that out
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
const subject = user.providerId.split('/').pop();
|
.map(async (user) => {
|
||||||
if (!subject) {
|
if (user.provider !== "oidc") {
|
||||||
return 'invalid-oidc';
|
return "no-oidc";
|
||||||
}
|
}
|
||||||
|
|
||||||
const role = await context.sessions.roleForSubject(subject);
|
const subject = getOidcSubject(user);
|
||||||
return role ?? 'no-role';
|
if (!subject) {
|
||||||
}
|
return "invalid-oidc";
|
||||||
|
}
|
||||||
|
|
||||||
// No role means the user is not registered in Headplane, but they
|
const role = await context.auth.roleForSubject(subject);
|
||||||
// are in Headscale. We also need to handle what happens if someone
|
return role ?? "no-role";
|
||||||
// logs into the UI and they don't have a Headscale setup.
|
}),
|
||||||
return 'no-role';
|
);
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
let magic: string | undefined;
|
let magic: string | undefined;
|
||||||
if (context.hs.readable()) {
|
if (context.hs.readable()) {
|
||||||
if (context.hs.c?.dns.magic_dns) {
|
if (context.hs.c?.dns.magic_dns) {
|
||||||
magic = context.hs.c.dns.base_domain;
|
magic = context.hs.c.dns.base_domain;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
// Build linkable Headscale users for admin link dialog
|
||||||
writable: writablePermission, // whether the user can write to the API
|
const claimed = await context.auth.claimedHeadscaleUserIds();
|
||||||
oidc: context.config.oidc,
|
const headscaleUsers = apiUsers.map((u) => ({
|
||||||
roles,
|
id: u.id,
|
||||||
magic,
|
name: getUserDisplayName(u),
|
||||||
users,
|
claimed: claimed.has(u.id),
|
||||||
};
|
}));
|
||||||
|
|
||||||
|
// 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 async function action(data: ActionFunctionArgs) {
|
export const action = userAction;
|
||||||
return userAction(data);
|
|
||||||
}
|
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||||
|
const [users, setUsers] = useState<UserMachine[]>(loaderData.users);
|
||||||
export default function Page() {
|
|
||||||
const data = useLoaderData<typeof loader>();
|
// This useEffect is entirely for the purpose of updating the users when the
|
||||||
const [users, setUsers] = useState<UserMachine[]>(data.users);
|
// 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
|
||||||
// This useEffect is entirely for the purpose of updating the users when the
|
// local state when it changes.
|
||||||
// drag and drop changes the machines between users. It's pretty hacky, but
|
useEffect(() => {
|
||||||
// the idea is to treat data.users as the source of truth and update the
|
setUsers(loaderData.users);
|
||||||
// local state when it changes.
|
}, [loaderData.users]);
|
||||||
useEffect(() => {
|
|
||||||
setUsers(data.users);
|
return (
|
||||||
}, [data.users]);
|
<>
|
||||||
|
<h1 className="mb-1.5 text-2xl font-medium">Users</h1>
|
||||||
return (
|
<p className="text-md mb-8">Manage the users in your network and their permissions.</p>
|
||||||
<>
|
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
|
||||||
<h1 className="text-2xl font-medium mb-1.5">Users</h1>
|
<div className="overflow-x-auto">
|
||||||
<p className="mb-8 text-md">
|
<table className="w-full min-w-[640px] table-auto rounded-lg">
|
||||||
Manage the users in your network and their permissions.
|
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||||
</p>
|
<tr className="px-0.5 text-left">
|
||||||
<ManageBanner isDisabled={!data.writable} oidc={data.oidc} />
|
<th className="pb-2 text-xs font-bold uppercase">User</th>
|
||||||
<table className="table-auto w-full rounded-lg">
|
<th className="pb-2 text-xs font-bold uppercase">Role</th>
|
||||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
<th className="pb-2 text-xs font-bold uppercase">Created At</th>
|
||||||
<tr className="text-left px-0.5">
|
<th className="pb-2 text-xs font-bold uppercase">Last Seen</th>
|
||||||
<th className="uppercase text-xs font-bold pb-2">User</th>
|
</tr>
|
||||||
<th className="uppercase text-xs font-bold pb-2">Role</th>
|
</thead>
|
||||||
<th className="uppercase text-xs font-bold pb-2">Created At</th>
|
<tbody
|
||||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
className={cn(
|
||||||
</tr>
|
"divide-y divide-headplane-100 dark:divide-headplane-800 align-top",
|
||||||
</thead>
|
"border-t border-headplane-100 dark:border-headplane-800",
|
||||||
<tbody
|
)}
|
||||||
className={cn(
|
>
|
||||||
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
|
{users
|
||||||
'border-t border-headplane-100 dark:border-headplane-800',
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
)}
|
.map((user) => (
|
||||||
>
|
<UserRow
|
||||||
{users
|
key={user.id}
|
||||||
.sort((a, b) => a.name.localeCompare(b.name))
|
currentLink={loaderData.userLinks[user.id]}
|
||||||
.map((user) => (
|
headscaleUsers={loaderData.headscaleUsers}
|
||||||
<UserRow
|
role={loaderData.roles[users.indexOf(user)]}
|
||||||
key={user.id}
|
user={user}
|
||||||
role={data.roles[users.indexOf(user)]}
|
/>
|
||||||
user={user}
|
))}
|
||||||
/>
|
</tbody>
|
||||||
))}
|
</table>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</>
|
||||||
</>
|
);
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+135
-136
@@ -1,140 +1,139 @@
|
|||||||
import { ActionFunctionArgs, data } from 'react-router';
|
import { data } from "react-router";
|
||||||
import type { LoadContext } from '~/server';
|
|
||||||
import { Capabilities, Roles } from '~/server/web/roles';
|
|
||||||
import { User } from '~/types';
|
|
||||||
import { data400, data403 } from '~/utils/res';
|
|
||||||
|
|
||||||
export async function userAction({
|
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||||
request,
|
import { Capabilities } from "~/server/web/roles";
|
||||||
context,
|
import type { Role } from "~/server/web/roles";
|
||||||
}: ActionFunctionArgs<LoadContext>) {
|
|
||||||
const session = await context.sessions.auth(request);
|
|
||||||
const check = await context.sessions.check(request, Capabilities.write_users);
|
|
||||||
if (!check) {
|
|
||||||
throw data403('You do not have permission to update users');
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = session.api_key;
|
import type { Route } from "./+types/overview";
|
||||||
const formData = await request.formData();
|
|
||||||
const action = formData.get('action_id')?.toString();
|
|
||||||
if (!action) {
|
|
||||||
throw data400('Missing `action_id` in the form data.');
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (action) {
|
export async function userAction({ request, context }: Route.ActionArgs) {
|
||||||
case 'create_user':
|
const principal = await context.auth.require(request);
|
||||||
return createUser(formData, apiKey, context);
|
const check = await context.auth.can(principal, Capabilities.write_users);
|
||||||
case 'delete_user':
|
if (!check) {
|
||||||
return deleteUser(formData, apiKey, context);
|
throw data("You do not have permission to update users", {
|
||||||
case 'rename_user':
|
status: 403,
|
||||||
return renameUser(formData, apiKey, context);
|
});
|
||||||
case 'reassign_user':
|
}
|
||||||
return reassignUser(formData, apiKey, context);
|
|
||||||
default:
|
const formData = await request.formData();
|
||||||
throw data400('Invalid `action_id` provided.');
|
const action = formData.get("action_id")?.toString();
|
||||||
}
|
if (!action) {
|
||||||
}
|
throw data("Missing `action_id` in the form data.", {
|
||||||
|
status: 404,
|
||||||
async function createUser(
|
});
|
||||||
formData: FormData,
|
}
|
||||||
apiKey: string,
|
|
||||||
context: LoadContext,
|
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||||
) {
|
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||||
const name = formData.get('username')?.toString();
|
switch (action) {
|
||||||
const displayName = formData.get('display_name')?.toString();
|
case "create_user": {
|
||||||
const email = formData.get('email')?.toString();
|
const name = formData.get("username")?.toString();
|
||||||
|
const displayName = formData.get("display_name")?.toString();
|
||||||
if (!name) {
|
const email = formData.get("email")?.toString();
|
||||||
throw data400('Missing `username` in the form data.');
|
|
||||||
}
|
if (!name) {
|
||||||
|
throw data("Missing `username` in the form data.", {
|
||||||
await context.client.post('v1/user', apiKey, {
|
status: 400,
|
||||||
name,
|
});
|
||||||
displayName,
|
}
|
||||||
email,
|
|
||||||
});
|
await api.createUser(name, email, displayName);
|
||||||
}
|
return { message: "User created successfully" };
|
||||||
|
}
|
||||||
async function deleteUser(
|
case "delete_user": {
|
||||||
formData: FormData,
|
const userId = formData.get("user_id")?.toString();
|
||||||
apiKey: string,
|
if (!userId) {
|
||||||
context: LoadContext,
|
throw data("Missing `user_id` in the form data.", {
|
||||||
) {
|
status: 400,
|
||||||
const userId = formData.get('user_id')?.toString();
|
});
|
||||||
if (!userId) {
|
}
|
||||||
throw data400('Missing `user_id` in the form data.');
|
|
||||||
}
|
await api.deleteUser(userId);
|
||||||
|
return { message: "User deleted successfully" };
|
||||||
await context.client.delete(`v1/user/${userId}`, apiKey);
|
}
|
||||||
}
|
case "rename_user": {
|
||||||
|
const userId = formData.get("user_id")?.toString();
|
||||||
async function renameUser(
|
const newName = formData.get("new_name")?.toString();
|
||||||
formData: FormData,
|
if (!userId || !newName) {
|
||||||
apiKey: string,
|
return data({ success: false }, 400);
|
||||||
context: LoadContext,
|
}
|
||||||
) {
|
|
||||||
const userId = formData.get('user_id')?.toString();
|
const users = await api.getUsers(userId);
|
||||||
const newName = formData.get('new_name')?.toString();
|
const user = users.find((user) => user.id === userId);
|
||||||
if (!userId || !newName) {
|
if (!user) {
|
||||||
return data({ success: false }, 400);
|
throw data(`No user found with id: ${userId}`, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { users } = await context.client.get<{ users: User[] }>(
|
if (user.provider === "oidc") {
|
||||||
'v1/user',
|
// OIDC users cannot be renamed via this endpoint, return an error
|
||||||
apiKey,
|
throw data("Users managed by OIDC cannot be renamed", {
|
||||||
);
|
status: 403,
|
||||||
|
});
|
||||||
const user = users.find((user) => user.id === userId);
|
}
|
||||||
if (!user) {
|
|
||||||
throw data400(`No user found with id: ${userId}`);
|
await api.renameUser(userId, newName);
|
||||||
}
|
return { message: "User renamed successfully" };
|
||||||
|
}
|
||||||
if (user.provider === 'oidc') {
|
case "reassign_user": {
|
||||||
// OIDC users cannot be renamed via this endpoint, return an error
|
const userId = formData.get("user_id")?.toString();
|
||||||
throw data403('Users managed by OIDC cannot be renamed');
|
const newRole = formData.get("new_role")?.toString();
|
||||||
}
|
if (!userId || !newRole) {
|
||||||
|
throw data("Missing `user_id` or `new_role` in the form data.", {
|
||||||
await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey);
|
status: 400,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
async function reassignUser(
|
|
||||||
formData: FormData,
|
const users = await api.getUsers(userId);
|
||||||
apiKey: string,
|
const user = users.find((user) => user.id === userId);
|
||||||
context: LoadContext,
|
if (!user) {
|
||||||
) {
|
throw data("Specified user not found", {
|
||||||
const userId = formData.get('user_id')?.toString();
|
status: 400,
|
||||||
const newRole = formData.get('new_role')?.toString();
|
});
|
||||||
if (!userId || !newRole) {
|
}
|
||||||
throw data400('Missing `user_id` or `new_role` in the form data.');
|
|
||||||
}
|
const subject = getOidcSubject(user);
|
||||||
|
if (!subject) {
|
||||||
const { users } = await context.client.get<{ users: User[] }>(
|
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
|
||||||
'v1/user',
|
}
|
||||||
apiKey,
|
|
||||||
);
|
const result = await context.auth.reassignSubject(subject, newRole as Role);
|
||||||
|
|
||||||
const user = users.find((user) => user.id === userId);
|
if (!result) {
|
||||||
if (!user?.providerId) {
|
throw data("Failed to reassign user role.", { status: 500 });
|
||||||
throw data400('Specified user is not an OIDC user');
|
}
|
||||||
}
|
|
||||||
|
return { message: "User reassigned successfully" };
|
||||||
// For some reason, headscale makes providerID a url where the
|
}
|
||||||
// last component is the subject, so we need to strip that out
|
case "link_user": {
|
||||||
const subject = user.providerId?.split('/').pop();
|
const userId = formData.get("user_id")?.toString();
|
||||||
if (!subject) {
|
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||||
throw data400(
|
if (!userId || !headscaleUserId) {
|
||||||
'Malformed `providerId` for the specified user. Cannot find subject.',
|
throw data("Missing `user_id` or `headscale_user_id` in the form data.", {
|
||||||
);
|
status: 400,
|
||||||
}
|
});
|
||||||
|
}
|
||||||
const result = await context.sessions.reassignSubject(
|
|
||||||
subject,
|
const users = await api.getUsers(userId);
|
||||||
newRole as keyof typeof Roles,
|
const user = users.find((user) => user.id === userId);
|
||||||
);
|
if (!user) {
|
||||||
|
throw data("Specified user not found", { status: 400 });
|
||||||
if (!result) {
|
}
|
||||||
return data({ success: false }, 403);
|
|
||||||
}
|
const subject = getOidcSubject(user);
|
||||||
|
if (!subject) {
|
||||||
return data({ success: true });
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { LoaderFunctionArgs } from 'react-router';
|
import type { Route } from './+types/healthz';
|
||||||
import type { LoadContext } from '~/server';
|
|
||||||
|
export async function loader({ context }: Route.LoaderArgs) {
|
||||||
|
// Use a fake API key for healthcheck
|
||||||
|
const api = context.hsApi.getRuntimeClient('fake-api-key');
|
||||||
|
const healthy = await api.isHealthy();
|
||||||
|
|
||||||
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
|
|
||||||
const healthy = await context.client.healthcheck();
|
|
||||||
return new Response(JSON.stringify({ status: healthy ? 'OK' : 'ERROR' }), {
|
return new Response(JSON.stringify({ status: healthy ? 'OK' : 'ERROR' }), {
|
||||||
status: healthy ? 200 : 500,
|
status: healthy ? 200 : 500,
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { versions } from 'node:process';
|
||||||
|
import { data } from 'react-router';
|
||||||
|
import type { Route } from './+types/info';
|
||||||
|
|
||||||
|
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||||
|
if (context.config.server.info_secret == null) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
status: 'Forbidden',
|
||||||
|
},
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bearer = request.headers.get('Authorization') ?? '';
|
||||||
|
if (!bearer.startsWith('Bearer ')) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
status: 'Unauthorized',
|
||||||
|
},
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = bearer.slice('Bearer '.length).trim();
|
||||||
|
if (token !== context.config.server.info_secret) {
|
||||||
|
throw data(
|
||||||
|
{
|
||||||
|
status: 'Forbidden',
|
||||||
|
},
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a fake API key for healthcheck
|
||||||
|
const api = context.hsApi.getRuntimeClient('fake-api-key');
|
||||||
|
const healthy = await api.isHealthy();
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
status: healthy ? 'healthy' : 'unhealthy',
|
||||||
|
headplane_version: __VERSION__,
|
||||||
|
headscale_canonical_version: healthy ? context.hsApi.apiVersion : 'unknown',
|
||||||
|
internal_versions: {
|
||||||
|
node: versions.node,
|
||||||
|
v8: versions.v8,
|
||||||
|
uv: versions.uv,
|
||||||
|
zlib: versions.zlib,
|
||||||
|
openssl: versions.openssl,
|
||||||
|
libc: versions.libc,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Headplane Server
|
|
||||||
This code is responsible for all code that is necessary *before* any
|
|
||||||
web server is started. It is the only part of the code that contains
|
|
||||||
many side-effects (in this case, importing a module may run code).
|
|
||||||
|
|
||||||
# Hierarchy
|
|
||||||
```
|
|
||||||
server
|
|
||||||
├── index.ts: Loads everything and starts the web server.
|
|
||||||
├── agent/
|
|
||||||
│ ├── dispatcher.ts: Serializes commands for the agent control fd (stdin).
|
|
||||||
│ ├── ssh.ts: Manages & multiplexes the active web SSH connections
|
|
||||||
│ ├── env.ts: Checks the environment variables for custom overrides.
|
|
||||||
├── config/
|
|
||||||
│ ├── integration/
|
|
||||||
│ │ ├── abstract.ts: Defines the abstract class for integrations.
|
|
||||||
│ │ ├── docker.ts: Contains the Docker integration.
|
|
||||||
│ │ ├── index.ts: Determines the correct integration to use (if any).
|
|
||||||
│ │ ├── kubernetes.ts: Contains the Kubernetes integration.
|
|
||||||
│ │ ├── proc.ts: Contains the Proc integration.
|
|
||||||
│ ├── env.ts: Checks the environment variables for custom overrides.
|
|
||||||
│ ├── loader.ts: Checks the configuration file and coalesces with ENV.
|
|
||||||
│ ├── schema.ts: Defines the schema for the Headplane configuration.
|
|
||||||
├── headscale/
|
|
||||||
│ ├── api-client.ts: Creates the HTTP client that talks to the Headscale API.
|
|
||||||
│ ├── api-error.ts: Contains the ResponseError definition.
|
|
||||||
│ ├── config-loader.ts: Loads the Headscale configuration (if available).
|
|
||||||
│ ├── config-schema.ts: Defines the schema for the Headscale configuration.
|
|
||||||
├── web/
|
|
||||||
│ ├── agent.ts: Handles setting up the agent WebSocket if needed.
|
|
||||||
│ ├── oidc.ts: Loads and validates an OIDC configuration (if available).
|
|
||||||
│ ├── roles.ts: Contains information about authentication permissions.
|
|
||||||
│ ├── sessions.ts: Initializes the session store and methods to manage it.
|
|
||||||
@@ -0,0 +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";
|
||||||
|
|
||||||
|
export const pathSupportedKeys = [
|
||||||
|
"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?",
|
||||||
|
|
||||||
|
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?",
|
||||||
|
|
||||||
|
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?",
|
||||||
|
});
|
||||||
|
|
||||||
|
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?",
|
||||||
|
});
|
||||||
|
|
||||||
|
const oidcConfig = type({
|
||||||
|
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`, "/");
|
||||||
|
|
||||||
|
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>?",
|
||||||
|
|
||||||
|
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
|
||||||
|
strict_validation: type("unknown").narrow(deprecatedField()).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const partialOidcConfig = type({
|
||||||
|
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"?',
|
||||||
|
|
||||||
|
// 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"',
|
||||||
|
});
|
||||||
|
|
||||||
|
const partialAgentConfig = type({
|
||||||
|
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(),
|
||||||
|
}).partial();
|
||||||
|
|
||||||
|
export const partialIntegrationConfig = type({
|
||||||
|
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");
|
||||||
|
|
||||||
|
export const partialHeadplaneConfig = type({
|
||||||
|
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 ObjectDeepMerge<T> = T extends object
|
||||||
|
? {
|
||||||
|
[K in keyof T]: T[K] extends object ? ObjectDeepMerge<T[K]> : T[K];
|
||||||
|
}
|
||||||
|
: T;
|
||||||
|
|
||||||
|
type ConfigWithPathKeys = ObjectDeepMerge<
|
||||||
|
DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig & ConfigWithPathKeys;
|
||||||
@@ -1,76 +0,0 @@
|
|||||||
import { exit } from 'node:process';
|
|
||||||
import { type } from 'arktype';
|
|
||||||
import log from '~/utils/log';
|
|
||||||
|
|
||||||
// Custom type for boolean environment variables, allowing for values like
|
|
||||||
// 1, true, yes, and on to count as a truthy value.
|
|
||||||
const booleanEnv = type('string | undefined').pipe((v) => {
|
|
||||||
return ['1', 'true', 'yes', 'on'].includes(v?.toLowerCase() ?? '');
|
|
||||||
});
|
|
||||||
|
|
||||||
export const envVariables = {
|
|
||||||
debugLog: 'HEADPLANE_DEBUG_LOG',
|
|
||||||
envOverrides: 'HEADPLANE_LOAD_ENV_OVERRIDES',
|
|
||||||
configPath: 'HEADPLANE_CONFIG_PATH',
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
export function configureLogger(env: string | undefined) {
|
|
||||||
const result = booleanEnv(env);
|
|
||||||
if (result instanceof type.errors) {
|
|
||||||
log.error(
|
|
||||||
'config',
|
|
||||||
'HEADPLANE_DEBUG_LOG value is invalid: %s',
|
|
||||||
result.summary,
|
|
||||||
);
|
|
||||||
log.info('config', 'Using a default value: false');
|
|
||||||
log.debug = () => {}; // Disable debug logging if the value is invalid
|
|
||||||
log.debugEnabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result === false) {
|
|
||||||
log.debug = () => {}; // Disable debug logging if the value is false
|
|
||||||
log.debugEnabled = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug('config', 'Debug logging has been enabled');
|
|
||||||
log.debug('config', 'It is recommended this be disabled in production');
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EnvOverrides {
|
|
||||||
loadEnv: boolean;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function configureConfig(overrides: {
|
|
||||||
loadEnv: string | undefined;
|
|
||||||
path: string | undefined;
|
|
||||||
}): EnvOverrides {
|
|
||||||
const loadResult = booleanEnv(overrides.loadEnv);
|
|
||||||
if (loadResult instanceof type.errors) {
|
|
||||||
log.error(
|
|
||||||
'config',
|
|
||||||
'HEADPLANE_LOAD_ENV_OVERRIDES value is invalid: %s',
|
|
||||||
loadResult.summary,
|
|
||||||
);
|
|
||||||
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const pathResult = type('string | undefined')(overrides.path);
|
|
||||||
if (pathResult instanceof type.errors) {
|
|
||||||
log.error(
|
|
||||||
'config',
|
|
||||||
'HEADPLANE_CONFIG_PATH value is invalid: %s',
|
|
||||||
pathResult.summary,
|
|
||||||
);
|
|
||||||
|
|
||||||
exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
loadEnv: loadResult,
|
|
||||||
path: pathResult ?? '/etc/headplane/config.yaml',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
interface ErrorCodes {
|
||||||
|
CONFLICTING_SECRET_PATH_FIELD: {
|
||||||
|
fieldName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
INVALID_REQUIRED_FIELDS: {
|
||||||
|
messages: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
MISSING_INTERPOLATION_VARIABLE: {
|
||||||
|
pathKey: string;
|
||||||
|
variableName: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
MISSING_SECRET_FILE: {
|
||||||
|
pathKey: string;
|
||||||
|
filePath: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const translationsWithVars: {
|
||||||
|
[K in keyof ErrorCodes]: (vars: ErrorCodes[K]) => string;
|
||||||
|
} = {
|
||||||
|
CONFLICTING_SECRET_PATH_FIELD: ({ fieldName }) =>
|
||||||
|
`Both "${fieldName}" and "${fieldName}_path" are set; please provide only one of these fields.`,
|
||||||
|
INVALID_REQUIRED_FIELDS: ({ messages }) =>
|
||||||
|
`The configuration is missing required fields or has invalid values:\n- ${messages.join('\n- ')}`,
|
||||||
|
MISSING_INTERPOLATION_VARIABLE: ({ pathKey, variableName }) =>
|
||||||
|
`Could not resolve environment variable "${variableName}" for configuration key "${pathKey}".`,
|
||||||
|
|
||||||
|
MISSING_SECRET_FILE: ({ pathKey, filePath }) =>
|
||||||
|
`The secret file specified in "${pathKey}" could not be accessed at path "${filePath}". Please ensure the file exists and is readable.`,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom error class for configuration-related errors.
|
||||||
|
*/
|
||||||
|
export class ConfigError extends Error {
|
||||||
|
/**
|
||||||
|
* The error code representing the type of configuration error.
|
||||||
|
*/
|
||||||
|
code: keyof ErrorCodes;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new ConfigError instance.
|
||||||
|
*
|
||||||
|
* @param code The error code
|
||||||
|
* @param vars The variables to interpolate into the error message
|
||||||
|
*/
|
||||||
|
constructor(code: keyof ErrorCodes, vars: unknown) {
|
||||||
|
super(
|
||||||
|
translationsWithVars[code](
|
||||||
|
vars as (typeof translationsWithVars)[typeof code] extends (
|
||||||
|
vars: infer U,
|
||||||
|
) => string
|
||||||
|
? U
|
||||||
|
: never,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
this.code = code;
|
||||||
|
this.name = 'ConfigError';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory method to create a ConfigError instance.
|
||||||
|
*
|
||||||
|
* @param code The error code
|
||||||
|
* @param vars The variables to interpolate into the error message
|
||||||
|
* @returns A new ConfigError instance
|
||||||
|
*/
|
||||||
|
static from<K extends keyof ErrorCodes>(code: K, vars: ErrorCodes[K]) {
|
||||||
|
return new ConfigError(code, vars);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { ApiClient } from '~/server/headscale/api-client';
|
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
|
||||||
|
|
||||||
export abstract class Integration<T> {
|
export abstract class Integration<T> {
|
||||||
protected context: NonNullable<T>;
|
protected context: NonNullable<T>;
|
||||||
@@ -11,6 +11,6 @@ export abstract class Integration<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
abstract isAvailable(): Promise<boolean> | boolean;
|
abstract isAvailable(): Promise<boolean> | boolean;
|
||||||
abstract onConfigChange(client: ApiClient): Promise<void> | void;
|
abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void;
|
||||||
abstract get name(): string;
|
abstract get name(): string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { constants, access } from 'node:fs/promises';
|
import { access, constants } from 'node:fs/promises';
|
||||||
import { setTimeout } from 'node:timers/promises';
|
import { setTimeout } from 'node:timers/promises';
|
||||||
|
import { type } from 'arktype';
|
||||||
import { Client } from 'undici';
|
import { Client } from 'undici';
|
||||||
import { ApiClient } from '~/server/headscale/api-client';
|
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
|
||||||
import log from '~/utils/log';
|
import log from '~/utils/log';
|
||||||
import type { HeadplaneConfig } from '../schema';
|
|
||||||
import { Integration } from './abstract';
|
import { Integration } from './abstract';
|
||||||
|
|
||||||
interface DockerContainer {
|
interface DockerContainer {
|
||||||
@@ -11,8 +11,25 @@ interface DockerContainer {
|
|||||||
Names: string[];
|
Names: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
type T = NonNullable<HeadplaneConfig['integration']>['docker'];
|
const configSchema = {
|
||||||
export default class DockerIntegration extends Integration<T> {
|
full: type({
|
||||||
|
enabled: 'boolean',
|
||||||
|
container_name: 'string?',
|
||||||
|
container_label: 'string = "me.tale.headplane.target=headscale"',
|
||||||
|
socket: 'string = "unix:///var/run/docker.sock"',
|
||||||
|
}),
|
||||||
|
|
||||||
|
partial: type({
|
||||||
|
enabled: 'boolean?',
|
||||||
|
container_name: 'string?',
|
||||||
|
container_label: 'string?',
|
||||||
|
socket: 'string?',
|
||||||
|
}).partial(),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default class DockerIntegration extends Integration<
|
||||||
|
typeof configSchema.full.infer
|
||||||
|
> {
|
||||||
private maxAttempts = 10;
|
private maxAttempts = 10;
|
||||||
private client: Client | undefined;
|
private client: Client | undefined;
|
||||||
private containerId: string | undefined;
|
private containerId: string | undefined;
|
||||||
@@ -21,6 +38,10 @@ export default class DockerIntegration extends Integration<T> {
|
|||||||
return 'Docker';
|
return 'Docker';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static get configSchema() {
|
||||||
|
return configSchema;
|
||||||
|
}
|
||||||
|
|
||||||
async getContainerName(label: string, value: string): Promise<string> {
|
async getContainerName(label: string, value: string): Promise<string> {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
throw new Error('Docker client is not initialized');
|
throw new Error('Docker client is not initialized');
|
||||||
@@ -60,7 +81,7 @@ export default class DockerIntegration extends Integration<T> {
|
|||||||
// Basic configuration check, the name overrides the container_label
|
// Basic configuration check, the name overrides the container_label
|
||||||
// selector because of legacy support.
|
// selector because of legacy support.
|
||||||
const { container_name, container_label } = this.context;
|
const { container_name, container_label } = this.context;
|
||||||
if (container_name.length === 0 && container_label.length === 0) {
|
if (container_name?.length === 0 && container_label.length === 0) {
|
||||||
log.error(
|
log.error(
|
||||||
'config',
|
'config',
|
||||||
'Missing a Docker `container_name` or `container_label`',
|
'Missing a Docker `container_name` or `container_label`',
|
||||||
@@ -94,7 +115,7 @@ export default class DockerIntegration extends Integration<T> {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
log.info('config', 'Checking API: %s', fetchU);
|
log.info('config', 'Checking API: %s', fetchU);
|
||||||
await fetch(new URL('/v1.30/version', fetchU).href);
|
await fetch(new URL('/v1.44/version', fetchU).href);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error('config', 'Failed to connect to Docker API: %s', error);
|
log.error('config', 'Failed to connect to Docker API: %s', error);
|
||||||
log.debug('config', 'Connection error: %o', error);
|
log.debug('config', 'Connection error: %o', error);
|
||||||
@@ -127,7 +148,7 @@ export default class DockerIntegration extends Integration<T> {
|
|||||||
|
|
||||||
const qp = new URLSearchParams({
|
const qp = new URLSearchParams({
|
||||||
filters: JSON.stringify(
|
filters: JSON.stringify(
|
||||||
container_name.length > 0
|
container_name != null && container_name.length > 0
|
||||||
? { name: [container_name] }
|
? { name: [container_name] }
|
||||||
: { label: [container_label] },
|
: { label: [container_label] },
|
||||||
),
|
),
|
||||||
@@ -140,7 +161,7 @@ export default class DockerIntegration extends Integration<T> {
|
|||||||
);
|
);
|
||||||
const res = await this.client.request({
|
const res = await this.client.request({
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
path: `/v1.30/containers/json?${qp.toString()}`,
|
path: `/v1.44/containers/json?${qp.toString()}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res.statusCode !== 200) {
|
if (res.statusCode !== 200) {
|
||||||
@@ -151,7 +172,7 @@ export default class DockerIntegration extends Integration<T> {
|
|||||||
|
|
||||||
const data = (await res.body.json()) as DockerContainer[];
|
const data = (await res.body.json()) as DockerContainer[];
|
||||||
if (data.length > 1) {
|
if (data.length > 1) {
|
||||||
if (container_name.length > 0) {
|
if (container_name != null && container_name.length > 0) {
|
||||||
log.error(
|
log.error(
|
||||||
'config',
|
'config',
|
||||||
`Found multiple containers with name ${container_name}`,
|
`Found multiple containers with name ${container_name}`,
|
||||||
@@ -167,7 +188,7 @@ export default class DockerIntegration extends Integration<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
if (container_name.length > 0) {
|
if (container_name != null && container_name.length > 0) {
|
||||||
log.error(
|
log.error(
|
||||||
'config',
|
'config',
|
||||||
`No container found with the name ${container_name}`,
|
`No container found with the name ${container_name}`,
|
||||||
@@ -193,7 +214,7 @@ export default class DockerIntegration extends Integration<T> {
|
|||||||
return this.client !== undefined && this.containerId !== undefined;
|
return this.client !== undefined && this.containerId !== undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
async onConfigChange(client: ApiClient) {
|
async onConfigChange(client: RuntimeApiClient) {
|
||||||
if (!this.client) {
|
if (!this.client) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -211,7 +232,7 @@ export default class DockerIntegration extends Integration<T> {
|
|||||||
|
|
||||||
const response = await this.client.request({
|
const response = await this.client.request({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
path: `/v1.30/containers/${this.containerId}/restart`,
|
path: `/v1.44/containers/${this.containerId}/restart`,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.statusCode !== 204) {
|
if (response.statusCode !== 204) {
|
||||||
@@ -233,14 +254,14 @@ export default class DockerIntegration extends Integration<T> {
|
|||||||
while (attempts <= this.maxAttempts) {
|
while (attempts <= this.maxAttempts) {
|
||||||
try {
|
try {
|
||||||
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
|
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
|
||||||
const status = await client.healthcheck();
|
const status = await client.isHealthy();
|
||||||
if (status === false) {
|
if (status === false) {
|
||||||
throw new Error('Headscale is not running');
|
throw new Error('Headscale is not running');
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info('config', 'Headscale is up and running');
|
log.info('config', 'Headscale is up and running');
|
||||||
return;
|
return;
|
||||||
} catch (error) {
|
} catch {
|
||||||
if (attempts < this.maxAttempts) {
|
if (attempts < this.maxAttempts) {
|
||||||
attempts++;
|
attempts++;
|
||||||
await setTimeout(1000);
|
await setTimeout(1000);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { HeadplaneConfig } from '~/server/config/schema';
|
|
||||||
import log from '~/utils/log';
|
import log from '~/utils/log';
|
||||||
|
import type { HeadplaneConfig } from '../config-schema';
|
||||||
import dockerIntegration from './docker';
|
import dockerIntegration from './docker';
|
||||||
import kubernetesIntegration from './kubernetes';
|
import kubernetesIntegration from './kubernetes';
|
||||||
import procIntegration from './proc';
|
import procIntegration from './proc';
|
||||||
@@ -47,16 +47,16 @@ function getIntegration(integration: HeadplaneConfig['integration']) {
|
|||||||
|
|
||||||
if (docker?.enabled) {
|
if (docker?.enabled) {
|
||||||
log.info('config', 'Using Docker integration');
|
log.info('config', 'Using Docker integration');
|
||||||
return new dockerIntegration(integration?.docker);
|
return new dockerIntegration(integration!.docker!);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (k8s?.enabled) {
|
if (k8s?.enabled) {
|
||||||
log.info('config', 'Using Kubernetes integration');
|
log.info('config', 'Using Kubernetes integration');
|
||||||
return new kubernetesIntegration(integration?.kubernetes);
|
return new kubernetesIntegration(integration!.kubernetes!);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (proc?.enabled) {
|
if (proc?.enabled) {
|
||||||
log.info('config', 'Using Proc integration');
|
log.info('config', 'Using Proc integration');
|
||||||
return new procIntegration(integration?.proc);
|
return new procIntegration(integration!.proc!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,246 +1,167 @@
|
|||||||
import { readFile, readdir } from 'node:fs/promises';
|
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";
|
||||||
import { platform } from 'node:os';
|
import { type } from "arktype";
|
||||||
import { join, resolve } from 'node:path';
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
import { kill } from 'node:process';
|
import { platform } from "node:os";
|
||||||
import { setTimeout } from 'node:timers/promises';
|
import { join } from "node:path";
|
||||||
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
|
||||||
import { ApiClient } from '~/server/headscale/api-client';
|
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||||
import log from '~/utils/log';
|
|
||||||
import { HeadplaneConfig } from '../schema';
|
import log from "~/utils/log";
|
||||||
import { Integration } from './abstract';
|
|
||||||
|
import { Integration } from "./abstract";
|
||||||
|
import { findHeadscaleServe, signalAndWaitHealthy } from "./proc-helper";
|
||||||
|
|
||||||
// https://github.com/kubernetes-client/javascript/blob/055b83c6504dfd1b2a2d081efd974163c6cbb808/src/config.ts#L40
|
// https://github.com/kubernetes-client/javascript/blob/055b83c6504dfd1b2a2d081efd974163c6cbb808/src/config.ts#L40
|
||||||
const svcRoot = '/var/run/secrets/kubernetes.io/serviceaccount';
|
const svcRoot = "/var/run/secrets/kubernetes.io/serviceaccount";
|
||||||
const svcCaPath = `${svcRoot}/ca.crt`;
|
const svcCaPath = `${svcRoot}/ca.crt`;
|
||||||
const svcTokenPath = `${svcRoot}/token`;
|
const svcTokenPath = `${svcRoot}/token`;
|
||||||
const svcNamespacePath = `${svcRoot}/namespace`;
|
const svcNamespacePath = `${svcRoot}/namespace`;
|
||||||
|
|
||||||
type T = NonNullable<HeadplaneConfig['integration']>['kubernetes'];
|
const configSchema = {
|
||||||
export default class KubernetesIntegration extends Integration<T> {
|
full: type({
|
||||||
private pid: number | undefined;
|
enabled: "boolean",
|
||||||
private maxAttempts = 10;
|
pod_name: "string",
|
||||||
|
validate_manifest: "boolean = true",
|
||||||
|
}),
|
||||||
|
|
||||||
get name() {
|
partial: type({
|
||||||
return 'Kubernetes (k8s)';
|
enabled: "boolean?",
|
||||||
}
|
pod_name: "string?",
|
||||||
|
validate_manifest: "boolean?",
|
||||||
|
}).partial(),
|
||||||
|
};
|
||||||
|
|
||||||
async isAvailable() {
|
export default class KubernetesIntegration extends Integration<typeof configSchema.full.infer> {
|
||||||
if (platform() !== 'linux') {
|
private pid: number | undefined;
|
||||||
log.error('config', 'Kubernetes is only available on Linux');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
get name() {
|
||||||
log.debug('config', 'Checking Kubernetes service account at %s', svcRoot);
|
return "Kubernetes (k8s)";
|
||||||
const files = await readdir(svcRoot);
|
}
|
||||||
if (files.length === 0) {
|
|
||||||
log.error('config', 'Kubernetes service account not found');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
|
static get configSchema() {
|
||||||
const expectedFiles = [svcCaPath, svcTokenPath, svcNamespacePath];
|
return configSchema;
|
||||||
|
}
|
||||||
|
|
||||||
log.debug('config', 'Looking for %s', expectedFiles.join(', '));
|
async isAvailable() {
|
||||||
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
|
if (platform() !== "linux") {
|
||||||
log.error('config', 'Malformed Kubernetes service account');
|
log.error("config", "Kubernetes is only available on Linux");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
log.error('config', 'Failed to access %s: %s', svcRoot, error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug('config', 'Reading Kubernetes service account at %s', svcRoot);
|
try {
|
||||||
const namespace = await readFile(svcNamespacePath, 'utf8');
|
log.debug("config", "Checking Kubernetes service account at %s", svcRoot);
|
||||||
|
const files = await readdir(svcRoot);
|
||||||
|
if (files.length === 0) {
|
||||||
|
log.error("config", "Kubernetes service account not found");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Some very ugly nesting but it's necessary
|
const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
|
||||||
if (this.context.validate_manifest === false) {
|
const expectedFiles = [svcCaPath, svcTokenPath, svcNamespacePath];
|
||||||
log.warn('config', 'Skipping strict Pod status check');
|
|
||||||
} else {
|
|
||||||
const pod = this.context.pod_name;
|
|
||||||
if (!pod) {
|
|
||||||
log.error('config', 'Missing POD_NAME variable');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pod.trim().length === 0) {
|
log.debug("config", "Looking for %s", expectedFiles.join(", "));
|
||||||
log.error('config', 'Pod name is empty');
|
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
|
||||||
return false;
|
log.error("config", "Malformed Kubernetes service account");
|
||||||
}
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log.error("config", "Failed to access %s: %s", svcRoot, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
log.debug(
|
log.debug("config", "Reading Kubernetes service account at %s", svcRoot);
|
||||||
'config',
|
const namespace = await readFile(svcNamespacePath, "utf8");
|
||||||
'Checking Kubernetes pod %s in namespace %s',
|
|
||||||
pod,
|
|
||||||
namespace,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
// Some very ugly nesting but it's necessary
|
||||||
log.debug('config', 'Attempgin to get cluster KubeConfig');
|
if (this.context.validate_manifest === false) {
|
||||||
const kc = new KubeConfig();
|
log.warn("config", "Skipping strict Pod status check");
|
||||||
kc.loadFromCluster();
|
} else {
|
||||||
|
const pod = this.context.pod_name;
|
||||||
|
if (!pod) {
|
||||||
|
log.error("config", "Missing POD_NAME variable");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const cluster = kc.getCurrentCluster();
|
if (pod.trim().length === 0) {
|
||||||
if (!cluster) {
|
log.error("config", "Pod name is empty");
|
||||||
log.error('config', 'Malformed kubeconfig');
|
return false;
|
||||||
return false;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
log.info(
|
log.debug("config", "Checking Kubernetes pod %s in namespace %s", pod, namespace);
|
||||||
'config',
|
|
||||||
'Service account connected to %s (%s)',
|
|
||||||
cluster.name,
|
|
||||||
cluster.server,
|
|
||||||
);
|
|
||||||
|
|
||||||
const kCoreV1Api = kc.makeApiClient(CoreV1Api);
|
try {
|
||||||
|
log.debug("config", "Attempgin to get cluster KubeConfig");
|
||||||
|
const kc = new KubeConfig();
|
||||||
|
kc.loadFromCluster();
|
||||||
|
|
||||||
log.info('config', 'Checking pod %s in namespace %s', pod, namespace);
|
const cluster = kc.getCurrentCluster();
|
||||||
log.debug('config', 'Reading pod info for %s', pod);
|
if (!cluster) {
|
||||||
const body = await kCoreV1Api.readNamespacedPod({
|
log.error("config", "Malformed kubeconfig");
|
||||||
name: pod,
|
return false;
|
||||||
namespace,
|
}
|
||||||
});
|
|
||||||
|
|
||||||
if (!body.spec) {
|
log.info("config", "Service account connected to %s (%s)", cluster.name, cluster.server);
|
||||||
log.error(
|
|
||||||
'config',
|
|
||||||
'Missing spec in pod info for %s/%s',
|
|
||||||
pod,
|
|
||||||
namespace,
|
|
||||||
);
|
|
||||||
|
|
||||||
return false;
|
const kCoreV1Api = kc.makeApiClient(CoreV1Api);
|
||||||
}
|
|
||||||
|
|
||||||
log.debug('config', 'Got pod info: %o', body.spec);
|
log.info("config", "Checking pod %s in namespace %s", pod, namespace);
|
||||||
const shared = body.spec.shareProcessNamespace;
|
log.debug("config", "Reading pod info for %s", pod);
|
||||||
if (shared === undefined) {
|
const body = await kCoreV1Api.readNamespacedPod({
|
||||||
log.error(
|
name: pod,
|
||||||
'config',
|
namespace,
|
||||||
'Pod does not have spec.shareProcessNamespace set',
|
});
|
||||||
);
|
|
||||||
|
|
||||||
return false;
|
if (!body.spec) {
|
||||||
}
|
log.error("config", "Missing spec in pod info for %s/%s", pod, namespace);
|
||||||
|
|
||||||
if (!shared) {
|
return false;
|
||||||
log.error(
|
}
|
||||||
'config',
|
|
||||||
'Pod has set but disabled spec.shareProcessNamespace',
|
|
||||||
);
|
|
||||||
|
|
||||||
return false;
|
log.debug("config", "Got pod info: %o", body.spec);
|
||||||
}
|
const shared = body.spec.shareProcessNamespace;
|
||||||
|
if (shared === undefined) {
|
||||||
|
log.error("config", "Pod does not have spec.shareProcessNamespace set");
|
||||||
|
|
||||||
log.info('config', 'Pod %s enabled shared processes', pod);
|
return false;
|
||||||
} catch (error) {
|
}
|
||||||
log.error('config', 'Failed to read pod info: %s', error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug('config', 'Looking for namespaced process in /proc');
|
if (!shared) {
|
||||||
const dir = resolve('/proc');
|
log.error("config", "Pod has set but disabled spec.shareProcessNamespace");
|
||||||
try {
|
|
||||||
const subdirs = await readdir(dir);
|
|
||||||
const promises = subdirs.map(async (dir) => {
|
|
||||||
const pid = Number.parseInt(dir, 10);
|
|
||||||
|
|
||||||
if (Number.isNaN(pid)) {
|
return false;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const path = join('/proc', dir, 'comm');
|
log.info("config", "Pod %s enabled shared processes", pod);
|
||||||
try {
|
} catch (error) {
|
||||||
log.debug('config', 'Reading %s', path);
|
log.error("config", "Failed to read pod info: %s", error);
|
||||||
const data = await readFile(path, 'utf8');
|
return false;
|
||||||
if (data.trim() !== 'headscale') {
|
}
|
||||||
throw new Error(
|
}
|
||||||
`Found PID with unexpected command: ${data.trim()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pid;
|
try {
|
||||||
} catch (error) {
|
const result = await findHeadscaleServe();
|
||||||
log.debug('config', 'Failed to read %s: %s', path, error);
|
if (!result) {
|
||||||
}
|
log.error("config", "Could not find headscale serve process");
|
||||||
});
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const results = await Promise.allSettled(promises);
|
this.pid = result;
|
||||||
const pids = [];
|
log.info("config", "Found headscale serve (PID %d)", this.pid);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
log.error("config", "Failed to scan /proc: %s", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const result of results) {
|
async onConfigChange(client: RuntimeApiClient) {
|
||||||
if (result.status === 'fulfilled' && result.value) {
|
if (!this.pid) {
|
||||||
pids.push(result.value);
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
log.debug('config', 'Found Headscale processes: %o', pids);
|
await signalAndWaitHealthy(client, {
|
||||||
if (pids.length > 1) {
|
pid: this.pid,
|
||||||
log.error(
|
signal: "SIGHUP",
|
||||||
'config',
|
});
|
||||||
'Found %d Headscale processes: %s',
|
}
|
||||||
pids.length,
|
|
||||||
pids.join(', '),
|
|
||||||
);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pids.length === 0) {
|
|
||||||
log.error('config', 'Could not find Headscale process');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pid = pids[0];
|
|
||||||
log.info('config', 'Found Headscale process with PID: %d', this.pid);
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
log.error('config', 'Failed to read /proc');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onConfigChange(client: ApiClient) {
|
|
||||||
if (!this.pid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
log.info('config', 'Sending SIGTERM to Headscale');
|
|
||||||
kill(this.pid, 'SIGTERM');
|
|
||||||
} catch (error) {
|
|
||||||
log.error('config', 'Failed to send SIGTERM to Headscale: %s', error);
|
|
||||||
log.debug('config', 'kill(1) error: %o', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
await setTimeout(1000);
|
|
||||||
let attempts = 0;
|
|
||||||
while (attempts <= this.maxAttempts) {
|
|
||||||
try {
|
|
||||||
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
|
|
||||||
const status = await client.healthcheck();
|
|
||||||
if (status === false) {
|
|
||||||
throw new Error('Headscale is not running');
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info('config', 'Headscale is up and running');
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
if (attempts < this.maxAttempts) {
|
|
||||||
attempts++;
|
|
||||||
await setTimeout(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.error(
|
|
||||||
'config',
|
|
||||||
'Missed restart deadline for Headscale (pid %d)',
|
|
||||||
this.pid,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import { readdir, readFile } from "node:fs/promises";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { kill } from "node:process";
|
||||||
|
import { setTimeout } from "node:timers/promises";
|
||||||
|
|
||||||
|
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||||
|
|
||||||
|
import log from "~/utils/log";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Does a two-stage scan of /proc to find the headscale process that is running
|
||||||
|
* a "serve" subcommand. It first scans all processes' comm files to find
|
||||||
|
* headscale processes, then checks their cmdline files to see if "serve" is
|
||||||
|
* the second argument.
|
||||||
|
*
|
||||||
|
* @param procPath The path to the proc filesystem (default: /proc)
|
||||||
|
* @returns The PID of the headscale serve process, or undefined if not found
|
||||||
|
*/
|
||||||
|
export async function findHeadscaleServe(procPath = "/proc"): Promise<number | undefined> {
|
||||||
|
const subdirs = await readdir(procPath);
|
||||||
|
const commResults = await Promise.allSettled(
|
||||||
|
subdirs.map(async (entry) => {
|
||||||
|
const pid = Number.parseInt(entry, 10);
|
||||||
|
if (Number.isNaN(pid)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const comm = await readFile(join(procPath, entry, "comm"), "utf8");
|
||||||
|
return comm.trim() === "headscale" ? pid : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const headscalePids = commResults
|
||||||
|
.map((result) => {
|
||||||
|
if (result.status === "fulfilled" && result.value !== undefined) {
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
})
|
||||||
|
.filter((pid): pid is number => pid !== undefined);
|
||||||
|
|
||||||
|
if (headscalePids.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("config", "Found %d headscale process(es), checking for serve", headscalePids.length);
|
||||||
|
for (const pid of headscalePids) {
|
||||||
|
try {
|
||||||
|
const cmdline = await readFile(join(procPath, pid.toString(), "cmdline"), "utf8");
|
||||||
|
const args = cmdline.split("\0").filter(Boolean);
|
||||||
|
|
||||||
|
if (args[1] === "serve") {
|
||||||
|
return pid;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Process may have exited between stages
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for signaling the headscale process.
|
||||||
|
*/
|
||||||
|
export interface SignalHeadscaleOptions {
|
||||||
|
pid: number;
|
||||||
|
signal?: NodeJS.Signals;
|
||||||
|
maxAttempts?: number;
|
||||||
|
retryDelayMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sends a signal to the headscale process and waits for it to become healthy.
|
||||||
|
* @param client The RuntimeApiClient to check health
|
||||||
|
* @param options Options for signaling and waiting
|
||||||
|
* @returns True if headscale became healthy, false otherwise
|
||||||
|
*/
|
||||||
|
export async function signalAndWaitHealthy(
|
||||||
|
client: RuntimeApiClient,
|
||||||
|
options: SignalHeadscaleOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { pid, signal = "SIGHUP", maxAttempts = 10, retryDelayMs = 1000 } = options;
|
||||||
|
|
||||||
|
try {
|
||||||
|
kill(pid, signal);
|
||||||
|
log.info("config", "Sent %s to Headscale (PID %d)", signal, pid);
|
||||||
|
} catch (error) {
|
||||||
|
log.error("config", "Failed to send %s to PID %d: %s", signal, pid, error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await setTimeout(retryDelayMs);
|
||||||
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||||
|
try {
|
||||||
|
const healthy = await client.isHealthy();
|
||||||
|
if (healthy) {
|
||||||
|
log.info("config", "Headscale is healthy after restart");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Still restarting
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt < maxAttempts) {
|
||||||
|
await setTimeout(retryDelayMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.error("config", "Headscale did not become healthy after %d attempts", maxAttempts);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -1,152 +1,64 @@
|
|||||||
import { readFile, readdir } from 'node:fs/promises';
|
import { type } from "arktype";
|
||||||
import { platform } from 'node:os';
|
import { platform } from "node:os";
|
||||||
import { join, resolve } from 'node:path';
|
|
||||||
import { kill } from 'node:process';
|
|
||||||
import { setTimeout } from 'node:timers/promises';
|
|
||||||
import { ApiClient } from '~/server/headscale/api-client';
|
|
||||||
import log from '~/utils/log';
|
|
||||||
import { HeadplaneConfig } from '../schema';
|
|
||||||
import { Integration } from './abstract';
|
|
||||||
|
|
||||||
type T = NonNullable<HeadplaneConfig['integration']>['proc'];
|
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||||
export default class ProcIntegration extends Integration<T> {
|
|
||||||
private pid: number | undefined;
|
|
||||||
private maxAttempts = 10;
|
|
||||||
|
|
||||||
get name() {
|
import log from "~/utils/log";
|
||||||
return 'Native Linux (/proc)';
|
|
||||||
}
|
|
||||||
|
|
||||||
async isAvailable() {
|
import { Integration } from "./abstract";
|
||||||
if (platform() !== 'linux') {
|
import { findHeadscaleServe, signalAndWaitHealthy } from "./proc-helper";
|
||||||
log.error('config', '/proc is only available on Linux');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug('config', 'Checking /proc for Headscale process');
|
const configSchema = {
|
||||||
const dir = resolve('/proc');
|
full: type({
|
||||||
try {
|
enabled: "boolean",
|
||||||
const subdirs = await readdir(dir);
|
}),
|
||||||
const promises = subdirs.map(async (dir) => {
|
|
||||||
const pid = Number.parseInt(dir, 10);
|
|
||||||
|
|
||||||
if (Number.isNaN(pid)) {
|
partial: type({
|
||||||
return;
|
enabled: "boolean?",
|
||||||
}
|
}).partial(),
|
||||||
|
};
|
||||||
|
|
||||||
const path = join('/proc', dir, 'comm');
|
export default class ProcIntegration extends Integration<typeof configSchema.full.infer> {
|
||||||
try {
|
private pid: number | undefined;
|
||||||
log.debug('config', 'Reading %s', path);
|
|
||||||
const data = await readFile(path, 'utf8');
|
|
||||||
if (data.trim() !== 'headscale') {
|
|
||||||
throw new Error(
|
|
||||||
`Found PID with unexpected command: ${data.trim()}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pid;
|
get name() {
|
||||||
} catch (error) {
|
return "Native Linux (/proc)";
|
||||||
log.debug('config', 'Failed to read %s: %s', path, error);
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const results = await Promise.allSettled(promises);
|
static get configSchema() {
|
||||||
const pids = [];
|
return configSchema;
|
||||||
|
}
|
||||||
|
|
||||||
for (const result of results) {
|
async isAvailable() {
|
||||||
if (result.status === 'fulfilled' && result.value) {
|
if (platform() !== "linux") {
|
||||||
pids.push(result.value);
|
log.error("config", "/proc is only available on Linux");
|
||||||
}
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
log.debug('config', 'Found Headscale processes: %o', pids);
|
|
||||||
if (pids.length > 1) {
|
|
||||||
log.warn(
|
|
||||||
'config',
|
|
||||||
'Found %d Headscale processes: %s',
|
|
||||||
pids.length,
|
|
||||||
pids.join(', '),
|
|
||||||
);
|
|
||||||
|
|
||||||
log.debug('config', 'Checking if any of them have Parent PID = 1, assuming thats the correct PID');
|
|
||||||
const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/;
|
|
||||||
for (const pid of pids) {
|
|
||||||
const pidStatusPath = join('/proc', pid.toString(), 'status');
|
|
||||||
try {
|
|
||||||
log.debug('config', 'Reading %s', pidStatusPath);
|
|
||||||
const pidData = await readFile(pidStatusPath, 'utf8');
|
|
||||||
const ppidResult = pidData.match(ppidRegex);
|
|
||||||
|
|
||||||
if (ppidResult !== null) {
|
|
||||||
const potentialPPid = Number.parseInt(ppidResult[1], 10);
|
|
||||||
if (potentialPPid === 1) {
|
|
||||||
this.pid = pid;
|
|
||||||
log.info('config', 'Found potential Headscale process with PID: %d based on Parent PID = 1', this.pid);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log.error('config', 'Failed to read %s: %s', pidStatusPath, error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await findHeadscaleServe();
|
||||||
|
if (!result) {
|
||||||
|
log.error("config", "Could not find headscale serve process");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pids.length === 0) {
|
this.pid = result;
|
||||||
log.error('config', 'Could not find Headscale process');
|
log.info("config", "Found headscale serve (PID %d)", this.pid);
|
||||||
return false;
|
return true;
|
||||||
}
|
} catch (error) {
|
||||||
|
log.error("config", "Failed to scan /proc: %s", error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.pid = pids[0];
|
async onConfigChange(client: RuntimeApiClient) {
|
||||||
log.info('config', 'Found Headscale process with PID: %d', this.pid);
|
if (!this.pid) {
|
||||||
return true;
|
return;
|
||||||
} catch {
|
}
|
||||||
log.error('config', 'Failed to read /proc');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onConfigChange(client: ApiClient) {
|
await signalAndWaitHealthy(client, {
|
||||||
if (!this.pid) {
|
pid: this.pid,
|
||||||
return;
|
signal: "SIGHUP",
|
||||||
}
|
});
|
||||||
|
}
|
||||||
try {
|
|
||||||
log.info('config', 'Sending SIGTERM to Headscale');
|
|
||||||
kill(this.pid, 'SIGTERM');
|
|
||||||
} catch (error) {
|
|
||||||
log.error('config', 'Failed to send SIGTERM to Headscale: %s', error);
|
|
||||||
log.debug('config', 'kill(1) error: %o', error);
|
|
||||||
}
|
|
||||||
|
|
||||||
await setTimeout(1000);
|
|
||||||
let attempts = 0;
|
|
||||||
while (attempts <= this.maxAttempts) {
|
|
||||||
try {
|
|
||||||
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
|
|
||||||
const status = await client.healthcheck();
|
|
||||||
if (status === false) {
|
|
||||||
log.error('config', 'Headscale is not running');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info('config', 'Headscale is up and running');
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
if (attempts < this.maxAttempts) {
|
|
||||||
attempts++;
|
|
||||||
await setTimeout(1000);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.error(
|
|
||||||
'config',
|
|
||||||
'Missed restart deadline for Headscale (pid %d)',
|
|
||||||
this.pid,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { access, constants, readFile } from 'node:fs/promises';
|
||||||
|
import { type } from 'arktype';
|
||||||
|
import { load } from 'js-yaml';
|
||||||
|
import log from '~/utils/log';
|
||||||
|
import {
|
||||||
|
headplaneConfig,
|
||||||
|
PartialHeadplaneConfig,
|
||||||
|
partialHeadplaneConfig,
|
||||||
|
pathSupportedKeys,
|
||||||
|
} from './config-schema';
|
||||||
|
import { ConfigError } from './error';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entrypoint that attempts to load and merge configuration from both
|
||||||
|
* a YAML config file (if available) and environment variables. Importantly,
|
||||||
|
* the environment variables will override any values set in the config file.
|
||||||
|
*
|
||||||
|
* The function also supports loading secret values from file paths for
|
||||||
|
* specific configuration keys (e.g., certificates, private keys) by checking
|
||||||
|
* for corresponding `_path` suffixed environment variables or config file
|
||||||
|
* entries.
|
||||||
|
*
|
||||||
|
* @param configPathOverride Used for testing to override the config file path
|
||||||
|
* @returns @ref{HeadplaneConfig} The fully validated configuration
|
||||||
|
* @throws {Error} If there are validation errors in the final configuration
|
||||||
|
*/
|
||||||
|
export async function loadConfig(configPathOverride?: string) {
|
||||||
|
const configPath =
|
||||||
|
configPathOverride != null
|
||||||
|
? configPathOverride
|
||||||
|
: process.env.HEADPLANE_CONFIG_PATH != null
|
||||||
|
? String(process.env.HEADPLANE_CONFIG_PATH)
|
||||||
|
: '/etc/headplane/config.yaml';
|
||||||
|
|
||||||
|
const fileConfig = await loadConfigFile(configPath);
|
||||||
|
const envConfig = await loadConfigEnv();
|
||||||
|
|
||||||
|
const combinedConfig = deepMerge(fileConfig, envConfig);
|
||||||
|
await loadConfigKeyPaths(combinedConfig);
|
||||||
|
|
||||||
|
const finalConfig = headplaneConfig(combinedConfig);
|
||||||
|
if (finalConfig instanceof type.errors) {
|
||||||
|
throw ConfigError.from('INVALID_REQUIRED_FIELDS', {
|
||||||
|
messages: finalConfig.map((e) => e.toString()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to load configuration from a YAML file at the specified path.
|
||||||
|
* If the file is not accessible, it returns undefined.
|
||||||
|
*
|
||||||
|
* @param path The file path to load the configuration from
|
||||||
|
* @returns A partial configuration object or undefined
|
||||||
|
* @throws {Error} If there are validation errors in the loaded configuration
|
||||||
|
*/
|
||||||
|
export async function loadConfigFile(path: string) {
|
||||||
|
try {
|
||||||
|
await access(path, constants.R_OK);
|
||||||
|
} catch {
|
||||||
|
log.info('config', 'Could not access config file at path: %s', path);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawBuffer = await readFile(path, 'utf8');
|
||||||
|
const rawConfig = load(rawBuffer);
|
||||||
|
const config = partialHeadplaneConfig(rawConfig);
|
||||||
|
if (config instanceof type.errors) {
|
||||||
|
throw ConfigError.from('INVALID_REQUIRED_FIELDS', {
|
||||||
|
messages: config.map((e) => e.toString()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Loads configuration overrides from environment variables prefixed with
|
||||||
|
* `HEADPLANE_`. Nested configuration keys can be represented using double
|
||||||
|
* underscores (`__`). For example, `HEADPLANE_SERVER__PORT=8080` would set
|
||||||
|
* the `server.port` configuration key to `8080`.
|
||||||
|
*
|
||||||
|
* @returns A partial configuration object or undefined
|
||||||
|
* @throws {Error} If there are validation errors in the loaded configuration
|
||||||
|
*/
|
||||||
|
export async function loadConfigEnv() {
|
||||||
|
if (process.env.HEADPLANE_LOAD_ENV_OVERRIDES != null) {
|
||||||
|
log.warn(
|
||||||
|
'config',
|
||||||
|
'HEADPLANE_LOAD_ENV_OVERRIDES is deprecated and will be removed in future versions',
|
||||||
|
);
|
||||||
|
log.warn(
|
||||||
|
'config',
|
||||||
|
'Environment variables are always loaded and `.env` files are no longer supported',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawConfig: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(process.env)) {
|
||||||
|
if (value == null || !key.startsWith('HEADPLANE_')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = parseEnvValue(value);
|
||||||
|
const configKey = key.slice('HEADPLANE_'.length).toLowerCase();
|
||||||
|
deepSet(rawConfig, configKey.split('__'), parsedValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = partialHeadplaneConfig(rawConfig);
|
||||||
|
if (config instanceof type.errors) {
|
||||||
|
throw ConfigError.from('INVALID_REQUIRED_FIELDS', {
|
||||||
|
messages: config.map((e) => e.toString()),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(config).length > 0 ? config : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deeply merges multiple objects together. Later objects in the arguments
|
||||||
|
* list will override properties of earlier objects.
|
||||||
|
*
|
||||||
|
* @param objects The objects to merge
|
||||||
|
* @returns The merged object
|
||||||
|
*/
|
||||||
|
function deepMerge<T>(...objects: (T | undefined)[]): T {
|
||||||
|
const result: { [key: string]: unknown } = {};
|
||||||
|
for (const obj of objects.filter((o) => o != null)) {
|
||||||
|
for (const [key, value] of Object.entries(
|
||||||
|
obj as {
|
||||||
|
[key: string]: unknown;
|
||||||
|
},
|
||||||
|
)) {
|
||||||
|
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
if (
|
||||||
|
result[key] == null ||
|
||||||
|
typeof result[key] !== 'object' ||
|
||||||
|
Array.isArray(result[key])
|
||||||
|
) {
|
||||||
|
result[key] = {};
|
||||||
|
}
|
||||||
|
result[key] = deepMerge(result[key], value);
|
||||||
|
} else {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a value deeply within an object based on the provided path.
|
||||||
|
*
|
||||||
|
* @param obj The object to set the value in
|
||||||
|
* @param path An array of keys representing the path to set
|
||||||
|
* @param value The value to set at the specified path
|
||||||
|
*/
|
||||||
|
function deepSet(
|
||||||
|
obj: { [key: string]: unknown },
|
||||||
|
path: string[],
|
||||||
|
value: unknown,
|
||||||
|
): void {
|
||||||
|
let current = obj;
|
||||||
|
for (let i = 0; i < path.length - 1; i++) {
|
||||||
|
const key = path[i];
|
||||||
|
if (current[key] == null || typeof current[key] !== 'object') {
|
||||||
|
current[key] = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current[key] as { [key: string]: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
current[path[path.length - 1]] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses an environment variable string value into an appropriate type.
|
||||||
|
* Supports booleans, null, undefined, and numbers. Falls back to string.
|
||||||
|
*
|
||||||
|
* @param value The environment variable string value
|
||||||
|
* @returns The parsed value
|
||||||
|
*/
|
||||||
|
function parseEnvValue(value: string): unknown {
|
||||||
|
const v = value.trim().toLowerCase();
|
||||||
|
if (v === 'true') return true;
|
||||||
|
if (v === 'false') return false;
|
||||||
|
if (v === 'null') return null;
|
||||||
|
if (v === 'undefined') return undefined;
|
||||||
|
|
||||||
|
if (/^-?\d+(\.\d+)?$/.test(v)) {
|
||||||
|
const num = Number(v);
|
||||||
|
if (!Number.isNaN(num)) return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For configuration keys that support loading from file paths (e.g.,
|
||||||
|
* certificates, private keys), this function checks for corresponding
|
||||||
|
* `_path` suffixed keys and loads the file content if the main key is
|
||||||
|
* not already set.
|
||||||
|
*
|
||||||
|
* @param partial The partial configuration object to update
|
||||||
|
*/
|
||||||
|
export async function loadConfigKeyPaths(partial: PartialHeadplaneConfig) {
|
||||||
|
for (const key of pathSupportedKeys) {
|
||||||
|
const pathKey = `${key}_path`;
|
||||||
|
const pathValue = deepGet(partial, pathKey.split('.'));
|
||||||
|
const existing = deepGet(partial, key.split('.'));
|
||||||
|
|
||||||
|
if (pathValue == null || typeof pathValue !== 'string') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing != null) {
|
||||||
|
throw ConfigError.from('CONFLICTING_SECRET_PATH_FIELD', {
|
||||||
|
fieldName: key,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const realPath = pathValue.replace(/\$\{([^}]+)\}/g, (_, variableName) => {
|
||||||
|
const value = process.env[variableName];
|
||||||
|
if (value === undefined) {
|
||||||
|
throw ConfigError.from('MISSING_INTERPOLATION_VARIABLE', {
|
||||||
|
pathKey: `${key}_path`,
|
||||||
|
variableName: variableName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const fileContent = await readFile(realPath, 'utf8');
|
||||||
|
deepSet(partial, key.split('.'), fileContent.trim().normalize());
|
||||||
|
} catch {
|
||||||
|
throw ConfigError.from('MISSING_SECRET_FILE', {
|
||||||
|
pathKey: `${key}_path`,
|
||||||
|
filePath: realPath,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deeply retrieves a value from an object based on the provided path.
|
||||||
|
*
|
||||||
|
* @param obj The object to retrieve the value from
|
||||||
|
* @param path An array of keys representing the path to retrieve
|
||||||
|
* @returns The value at the specified path or undefined if not found
|
||||||
|
*/
|
||||||
|
function deepGet(obj: { [key: string]: unknown }, path: string[]): unknown {
|
||||||
|
let current = obj;
|
||||||
|
for (const segment of path) {
|
||||||
|
if (current == null || typeof current !== 'object') {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
current = current[segment] as { [key: string]: unknown };
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}
|
||||||
@@ -1,303 +0,0 @@
|
|||||||
import { access, constants, readFile } from 'node:fs/promises';
|
|
||||||
import { env } from 'node:process';
|
|
||||||
import { type } from 'arktype';
|
|
||||||
import { configDotenv } from 'dotenv';
|
|
||||||
import { parseDocument } from 'yaml';
|
|
||||||
import log from '~/utils/log';
|
|
||||||
import { EnvOverrides, envVariables } from './env';
|
|
||||||
import {
|
|
||||||
HeadplaneConfig,
|
|
||||||
headplaneConfig,
|
|
||||||
partialHeadplaneConfig,
|
|
||||||
} from './schema';
|
|
||||||
|
|
||||||
// Custom error for config issues
|
|
||||||
export class ConfigError extends Error {
|
|
||||||
constructor(message: string) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'ConfigError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interpolate environment variables in a string
|
|
||||||
* Replaces ${VAR_NAME} patterns with the actual environment variable values
|
|
||||||
*/
|
|
||||||
export function interpolateEnvVars(str: string): string {
|
|
||||||
return str.replace(/\$\{([^}]+)\}/g, (_, varName) => {
|
|
||||||
const value = env[varName];
|
|
||||||
if (value === undefined) {
|
|
||||||
throw new ConfigError(`Environment variable "${varName}" not found`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadConfig is a has a lifetime of the entire application and is
|
|
||||||
// used to load the configuration for Headplane. It is called once.
|
|
||||||
//
|
|
||||||
// TODO: Potential for file watching on the configuration
|
|
||||||
// But this may not be necessary as a use-case anyways
|
|
||||||
export async function loadConfig({ loadEnv, path }: EnvOverrides) {
|
|
||||||
log.debug('config', 'Loading configuration file: %s', path);
|
|
||||||
await validateConfigPath(path);
|
|
||||||
|
|
||||||
const data = await loadConfigFile(path);
|
|
||||||
if (!data) {
|
|
||||||
throw new ConfigError('Failed to load configuration file');
|
|
||||||
}
|
|
||||||
|
|
||||||
let config = validateConfig({ ...data, debug: log.debugEnabled });
|
|
||||||
|
|
||||||
if (!loadEnv) {
|
|
||||||
log.debug('config', 'Environment variable overrides are disabled');
|
|
||||||
log.debug('config', 'This also disables the loading of a .env file');
|
|
||||||
const moddedConfig = await loadSecretsFromFiles(config);
|
|
||||||
log.debug('config', 'Loaded file-based secrets');
|
|
||||||
return moddedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info('config', 'Loading a .env file (if available)');
|
|
||||||
configDotenv({ override: true, quiet: true });
|
|
||||||
const merged = coalesceEnv(config);
|
|
||||||
if (merged) config = merged;
|
|
||||||
if (config.headscale && typeof config.headscale.config_path === 'string') {
|
|
||||||
config.headscale.config_path = interpolateEnvVars(
|
|
||||||
config.headscale.config_path,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const moddedConfig = await loadSecretsFromFiles(config);
|
|
||||||
log.debug('config', 'Loaded file-based secrets');
|
|
||||||
|
|
||||||
return moddedConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Recursively walks the config object; for any key in the whitelist of secret path keys,
|
|
||||||
* reads that file and assigns its contents to the corresponding key
|
|
||||||
* without the suffix, then removes the "_path" property.
|
|
||||||
*/
|
|
||||||
const SECRET_PATH_KEYS = [
|
|
||||||
'pre_authkey_path',
|
|
||||||
'client_secret_path',
|
|
||||||
'headscale_api_key_path',
|
|
||||||
'cookie_secret_path',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
// For fast set hashing lookups, but we still need the array for typings
|
|
||||||
const SECRET_PATH_KEY_SET = new Set<string>(SECRET_PATH_KEYS);
|
|
||||||
|
|
||||||
type SecretPathKey = (typeof SECRET_PATH_KEYS)[number];
|
|
||||||
type StripPath<S extends string> = S extends `${infer T}_path` ? T : never;
|
|
||||||
type KeysToPromote<T> = Extract<keyof T & string, SecretPathKey>;
|
|
||||||
type MappedKeys<T> = StripPath<KeysToPromote<T>>;
|
|
||||||
|
|
||||||
type NonNullablized<T> = Omit<T, KeysToPromote<T> | MappedKeys<T>> & {
|
|
||||||
[K in MappedKeys<T>]-?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NestedNonNullablized<T> = T extends readonly (infer U)[]
|
|
||||||
? readonly NestedNonNullablized<U>[]
|
|
||||||
: T extends (infer U)[]
|
|
||||||
? NestedNonNullablized<U>[]
|
|
||||||
: T extends object
|
|
||||||
? {
|
|
||||||
[K in keyof NonNullablized<T>]: NestedNonNullablized<
|
|
||||||
NonNullablized<T>[K]
|
|
||||||
>;
|
|
||||||
}
|
|
||||||
: T;
|
|
||||||
|
|
||||||
async function loadSecretsFromFiles<T extends object>(
|
|
||||||
obj: T,
|
|
||||||
): Promise<NestedNonNullablized<T>> {
|
|
||||||
// Work with a Record so we can mutate/delete properties
|
|
||||||
const record = obj as Record<string, unknown>;
|
|
||||||
|
|
||||||
for (const key of Object.keys(record)) {
|
|
||||||
const val = record[key];
|
|
||||||
|
|
||||||
if (val && typeof val === 'object') {
|
|
||||||
// recurse into nested objects
|
|
||||||
record[key] = await loadSecretsFromFiles(val);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (SECRET_PATH_KEY_SET.has(key) && typeof val === 'string') {
|
|
||||||
try {
|
|
||||||
const path = interpolateEnvVars(val);
|
|
||||||
const content = await readFile(path, 'utf8');
|
|
||||||
const secretKey = key.slice(0, -5); // drop '_path'
|
|
||||||
record[secretKey] = content.trim();
|
|
||||||
delete record[key];
|
|
||||||
log.debug('config', 'Loaded secret from %s → %s', val, secretKey);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof ConfigError) throw err;
|
|
||||||
log.error('config', 'Failed to read secret file %s: %s', val, err);
|
|
||||||
throw new ConfigError(`Failed to read secret file ${val}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cast back to the original T so callers keep their precise type
|
|
||||||
return record as NestedNonNullablized<T>;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function validateConfigPath(path: string) {
|
|
||||||
try {
|
|
||||||
await access(path, constants.F_OK | constants.R_OK);
|
|
||||||
log.info('config', 'Found a valid configuration file at %s', path);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
log.error('config', 'Unable to read a configuration file at %s', path);
|
|
||||||
log.error('config', '%s', error);
|
|
||||||
throw new ConfigError(
|
|
||||||
`Unable to read configuration file at ${path}: ${error}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadConfigFile(path: string): Promise<unknown> {
|
|
||||||
log.debug('config', 'Reading configuration file at %s', path);
|
|
||||||
try {
|
|
||||||
const data = await readFile(path, 'utf8');
|
|
||||||
const configYaml = parseDocument(data);
|
|
||||||
if (configYaml.errors.length > 0) {
|
|
||||||
log.error('config', 'Cannot parse configuration file at %s', path);
|
|
||||||
for (const error of configYaml.errors) {
|
|
||||||
log.error('config', ` - ${error.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new ConfigError(`Cannot parse configuration file at ${path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (configYaml.warnings.length > 0) {
|
|
||||||
log.warn(
|
|
||||||
'config',
|
|
||||||
'Warnings while parsing configuration file at %s',
|
|
||||||
path,
|
|
||||||
);
|
|
||||||
for (const warning of configYaml.warnings) {
|
|
||||||
log.warn('config', ` - ${warning.toString()}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return configYaml.toJSON() as unknown;
|
|
||||||
} catch (e) {
|
|
||||||
log.error('config', 'Error reading configuration file at %s', path);
|
|
||||||
log.error('config', '%s', e);
|
|
||||||
throw new ConfigError(`Error reading configuration file at ${path}: ${e}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateConfig(config: unknown) {
|
|
||||||
log.debug('config', 'Validating Headplane configuration');
|
|
||||||
const result = headplaneConfig(config);
|
|
||||||
if (result instanceof type.errors) {
|
|
||||||
const errorMessages = [];
|
|
||||||
for (const [number, error] of result.entries()) {
|
|
||||||
const errorMsg = error.toString();
|
|
||||||
log.error('config', ` - (${number}): ${errorMsg}`);
|
|
||||||
errorMessages.push(errorMsg);
|
|
||||||
}
|
|
||||||
throw new ConfigError(errorMessages.join('\n'));
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function coalesceEnv(config: HeadplaneConfig) {
|
|
||||||
const envConfig: Record<string, unknown> = {};
|
|
||||||
const rootKeys: string[] = Object.values(envVariables);
|
|
||||||
|
|
||||||
// Typescript is still insanely stupid at nullish filtering
|
|
||||||
const vars = Object.entries(env).filter(([key, value]) => {
|
|
||||||
if (!value) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!key.startsWith('HEADPLANE_')) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out the rootEnv configurations
|
|
||||||
if (rootKeys.includes(key)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}) as [string, string][];
|
|
||||||
|
|
||||||
log.debug('config', 'Coalescing %s environment variables', vars.length);
|
|
||||||
for (const [key, value] of vars) {
|
|
||||||
const configPath = key.replace('HEADPLANE_', '').toLowerCase().split('__');
|
|
||||||
log.debug(
|
|
||||||
'config',
|
|
||||||
` - ${key}=${new Array(value.length).fill('*').join('')}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
let current = envConfig;
|
|
||||||
while (configPath.length > 1) {
|
|
||||||
const path = configPath.shift() as string;
|
|
||||||
if (!(path in current)) {
|
|
||||||
current[path] = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
current = current[path] as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
current[configPath[0]] = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
const toMerge = coalesceConfig(envConfig);
|
|
||||||
if (!toMerge) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Deep merge the environment variables into the configuration
|
|
||||||
// This will overwrite any existing values in the configuration
|
|
||||||
return deepMerge(config, toMerge);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function coalesceConfig(config: unknown) {
|
|
||||||
log.debug('config', 'Revalidating config after coalescing variables');
|
|
||||||
const out = partialHeadplaneConfig(config);
|
|
||||||
if (out instanceof type.errors) {
|
|
||||||
log.error('config', 'Error parsing variables:');
|
|
||||||
for (const [number, error] of out.entries()) {
|
|
||||||
log.error('config', ` - (${number}): ${error.toString()}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
type DeepPartial<T> =
|
|
||||||
| {
|
|
||||||
[P in keyof T]?: DeepPartial<T[P]>;
|
|
||||||
}
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
function deepMerge<T>(target: T, source: DeepPartial<T>): T {
|
|
||||||
if (typeof target !== 'object' || typeof source !== 'object')
|
|
||||||
return source as T;
|
|
||||||
const result = { ...target } as T;
|
|
||||||
|
|
||||||
for (const key in source) {
|
|
||||||
const val = source[key];
|
|
||||||
if (val === undefined || val === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof val === 'object') {
|
|
||||||
result[key] = deepMerge(result[key], val);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
result[key] = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user