mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
118 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a967731461 | |||
| a1efe36ff1 | |||
| 270b99f063 | |||
| 434f886034 | |||
| 684a95b5e8 | |||
| 45984ec639 | |||
| 0f8e192b5c | |||
| 6d70497758 | |||
| f4a2811afb | |||
| 5f2cbebbcd | |||
| da3132323c | |||
| cb97b73abd | |||
| 432cd29cd0 | |||
| 1feb109a36 | |||
| f6613f1faf | |||
| 210d473bc2 | |||
| 67b1611d13 | |||
| df11aefd0c | |||
| f9dc9d454b | |||
| 64edd2041b | |||
| 1462016a39 | |||
| 779103147d | |||
| be79413178 | |||
| 787f8decbc | |||
| 0cff389051 | |||
| 3c5a653942 | |||
| 2e5bb791e8 | |||
| ab3c4f7819 | |||
| b2db6efd63 | |||
| 937bc4a667 | |||
| 9f4a7dc6e0 | |||
| 2afc11f652 | |||
| 624b9ab479 | |||
| dde72ae4ed | |||
| 20c2a71713 | |||
| 2fdbf4abea | |||
| 5e0ac88189 | |||
| 4cb231deb1 | |||
| 2a66e02bd4 | |||
| a385c72d90 | |||
| 4f59f4c77b | |||
| 20297c6800 | |||
| 7c39f8f3a8 | |||
| 2e6b387d67 | |||
| 5e45295523 | |||
| a6b25b3ba3 | |||
| badefc7f85 | |||
| 30dd718d68 | |||
| c96249f41e | |||
| c60a47b968 | |||
| 026b683c72 | |||
| 23df48bb2f | |||
| 9183ec2942 | |||
| e70319ae1a | |||
| 0b2ea70f33 | |||
| 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 | |||
| b938642da7 | |||
| e868f177aa |
@@ -1,31 +1,31 @@
|
||||
name: Bug Report
|
||||
description: Report an issue with Headplane
|
||||
assignees: [tale]
|
||||
labels: [bug, triage]
|
||||
labels: ["Bug", "Needs Triage"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
A detailed description of the issue and steps to reproduce it.
|
||||
If applicable, include any error messages or screenshots.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
A detailed description of the issue and steps to reproduce it.
|
||||
If applicable, include any error messages or screenshots.
|
||||
|
||||
If this is not an issue with Headplane, but an issue with your
|
||||
environment, please consider opening a discussion instead.
|
||||
placeholder: e.g. "When I try to upload a file, I get an error message."
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Headplane Version
|
||||
description: What version of Headplane are you using?
|
||||
placeholder: e.g. "v0.5.5"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Headscale Version
|
||||
description: What version of Headscale are you using?
|
||||
placeholder: e.g. "v0.25.1"
|
||||
validations:
|
||||
required: true
|
||||
If this is not an issue with Headplane, but an issue with your
|
||||
environment, please consider opening a discussion instead.
|
||||
placeholder: e.g. "When I try to upload a file, I get an error message."
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Headplane Version
|
||||
description: What version of Headplane are you using?
|
||||
placeholder: e.g. "v0.6.2"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Headscale Version
|
||||
description: What version of Headscale are you using?
|
||||
placeholder: e.g. "v0.27.1"
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Question or Support
|
||||
url: https://github.com/tale/headplane/discussions/new?category=q-a
|
||||
about: Ask questions or get help with your setup
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
name: Feature Request
|
||||
description: Request a new feature or enhancement for Headplane
|
||||
assignees: [tale]
|
||||
labels: [enhancement, triage]
|
||||
labels: ["Feature", "Needs Triage"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
A detailed description of the feature you would like to see added.
|
||||
Please include any relevant context, such as why this feature is
|
||||
important and how it would benefit other users beyond yourself.
|
||||
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
A detailed description of the feature you would like to see added.
|
||||
Please include any relevant context, such as why this feature is
|
||||
important and how it would benefit other users beyond yourself.
|
||||
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
Agent:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cmd/hp_agent/**
|
||||
- internal/**
|
||||
- go.mod
|
||||
- go.sum
|
||||
|
||||
UI/UX:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/components/**
|
||||
- app/layouts/**
|
||||
- app/root.tsx
|
||||
- app/tailwind.css
|
||||
|
||||
Authentication:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/server/web/**
|
||||
- app/routes/auth*/**
|
||||
|
||||
Config:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/server/config/**
|
||||
- internal/config/**
|
||||
- config.example.yaml
|
||||
|
||||
Integrations:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- app/server/headscale/**
|
||||
- app/openapi-*.json
|
||||
|
||||
Web SSH:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- cmd/hp_ssh/**
|
||||
|
||||
Docs:
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- docs/**
|
||||
- README.md
|
||||
@@ -19,7 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
name: Labeler
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
name: Label PR
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/labeler@v6
|
||||
with:
|
||||
sync-labels: true
|
||||
@@ -0,0 +1,87 @@
|
||||
name: Release Lifecycle
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
beta:
|
||||
name: Beta Release
|
||||
if: contains(github.ref_name, '-')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Label and comment on referenced issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "No previous tag found, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Processing issues referenced between ${PREV_TAG} and ${TAG}"
|
||||
ISSUES=$(git log --format="%B" "${PREV_TAG}..HEAD" | grep -oE '#[0-9]+' | sort -u | tr -d '#')
|
||||
|
||||
for ISSUE in $ISSUES; do
|
||||
[ -z "$ISSUE" ] && continue
|
||||
|
||||
STATE=$(gh issue view "$ISSUE" --json state --jq '.state' \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || echo "")
|
||||
[ "$STATE" != "OPEN" ] && continue
|
||||
|
||||
echo "Labeling and commenting on #${ISSUE}"
|
||||
gh issue edit "$ISSUE" --add-label "In Beta" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
gh issue comment "$ISSUE" \
|
||||
--body "🧪 Available in [\`${TAG}\`](https://github.com/${{ github.repository }}/releases/tag/${TAG})" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
done
|
||||
|
||||
stable:
|
||||
name: Stable Release
|
||||
if: ${{ !contains(github.ref_name, '-') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close milestone issues
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
TAG="${GITHUB_REF_NAME}"
|
||||
VERSION="${TAG#v}"
|
||||
|
||||
MILESTONE_NUMBER=$(gh api "repos/${{ github.repository }}/milestones" \
|
||||
--jq ".[] | select(.title == \"${VERSION}\") | .number" 2>/dev/null || echo "")
|
||||
|
||||
if [ -z "$MILESTONE_NUMBER" ]; then
|
||||
echo "No milestone found for ${VERSION}, skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "Processing milestone ${VERSION} (#${MILESTONE_NUMBER})"
|
||||
gh api "repos/${{ github.repository }}/issues?milestone=${MILESTONE_NUMBER}&state=open&per_page=100" \
|
||||
--paginate --jq '.[].number' 2>/dev/null | while IFS= read -r ISSUE; do
|
||||
[ -z "$ISSUE" ] && continue
|
||||
|
||||
echo "Closing #${ISSUE}"
|
||||
gh issue edit "$ISSUE" --remove-label "In Beta" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
gh issue comment "$ISSUE" \
|
||||
--body "✅ Released in [\`${TAG}\`](https://github.com/${{ github.repository }}/releases/tag/${TAG})" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
gh issue close "$ISSUE" \
|
||||
--repo "${{ github.repository }}" 2>/dev/null || true
|
||||
done
|
||||
|
||||
echo "Closing milestone ${VERSION}"
|
||||
gh api -X PATCH "repos/${{ github.repository }}/milestones/${MILESTONE_NUMBER}" \
|
||||
-f state=closed 2>/dev/null || true
|
||||
@@ -1,81 +0,0 @@
|
||||
name: Pre-release (next)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: pre-release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: write # Allow canceling in-progress runs
|
||||
contents: read # Read access to the repository
|
||||
packages: write # Write access to the container registry
|
||||
id-token: write # For the attest action to push
|
||||
attestations: write # For the attest action to push
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Ensure the action only runs if manually dispatched or a PR on the `next` branch in the *main* repository is opened or synchronized.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
|
||||
name: Docker Pre-release
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- target: final
|
||||
tag: 'next'
|
||||
- target: debug-shell
|
||||
tag: 'next-shell'
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=${{ matrix.tag }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and publish ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
uses: docker/build-push-action@v6
|
||||
id: push
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
target: ${{ matrix.target }}
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
secrets: |
|
||||
gh_token=${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Attestation Provenance for ghcr.io/${{ github.repository }}:${{ matrix.tag }}
|
||||
uses: actions/attest-build-provenance@v2
|
||||
id: attest
|
||||
with:
|
||||
subject-name: ghcr.io/${{ github.repository }}
|
||||
subject-digest: ${{ steps.push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
@@ -23,13 +23,13 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- target: final
|
||||
tag_suffix: ''
|
||||
tag_suffix: ""
|
||||
- target: debug-shell
|
||||
tag_suffix: '-shell'
|
||||
tag_suffix: "-shell"
|
||||
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
@@ -70,6 +70,7 @@ jobs:
|
||||
cache-to: type=gha,mode=max
|
||||
build-args: |
|
||||
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
HEADPLANE_VERSION=${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
|
||||
- name: Attestation Provenance for ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
|
||||
uses: actions/attest-build-provenance@v2
|
||||
id: attest
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
name: Stale
|
||||
on:
|
||||
schedule:
|
||||
- cron: "30 1 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
actions: write
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
name: Close stale issues
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
stale-issue-label: Stale
|
||||
only-labels: "Needs Info"
|
||||
days-before-stale: 30
|
||||
stale-issue-message: >
|
||||
This issue has been waiting for information for 30 days.
|
||||
It will be closed in 7 days if there is no further activity.
|
||||
Feel free to reopen if you can provide the requested details.
|
||||
close-issue-message: >
|
||||
Closed due to inactivity. Feel free to reopen with the
|
||||
requested information.
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Triage
|
||||
on:
|
||||
issues:
|
||||
types: [milestoned]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
remove-triage:
|
||||
name: Remove Needs Triage
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Remove Needs Triage label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh issue edit ${{ github.event.issue.number }} \
|
||||
--remove-label "Needs Triage" \
|
||||
--repo ${{ github.repository }} 2>/dev/null || true
|
||||
@@ -0,0 +1,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.
|
||||
+89
-27
@@ -1,4 +1,7 @@
|
||||
# 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)).
|
||||
- Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)).
|
||||
@@ -6,28 +9,52 @@
|
||||
- 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
|
||||
- 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.
|
||||
- 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)
|
||||
|
||||
- **Headplane now supports connecting to machines via SSH in the web browser.**
|
||||
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
|
||||
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
|
||||
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
|
||||
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
|
||||
- Begin using a new SQLite database file in `/var/lib/headplane/hp_persist.db`.
|
||||
- The database is created automatically if it does not exist.
|
||||
- It currently stores SSH connection details and HostInfo for the agent.
|
||||
- User information is automatically migrated from the previous database.
|
||||
- The database is created automatically if it does not exist.
|
||||
- It currently stores SSH connection details and HostInfo for the agent.
|
||||
- User information is automatically migrated from the previous database.
|
||||
- The docker container now runs in a distroless image (closes [#255](https://github.com/tale/headplane/issues/255)).
|
||||
- A debug version of the container that runs as root and has a shell is available as `ghcr.io/tale/headplane:<version>-shell`.
|
||||
- A debug version of the container that runs as root and has a shell is available as `ghcr.io/tale/headplane:<version>-shell`.
|
||||
- Removing a Split DNS record will no longer make the split domain unresolvable by clients (closes [#231](https://github.com/tale/headplane/issues/231)).
|
||||
- Reintroduce the toggle for overriding local DNS settings in the Headscale config (closes [#236](https://github.com/tale/headplane/issues/236)).
|
||||
- Prefer cross-compiling in the Dockerfile to speed up builds while still supporting multiple architectures.
|
||||
@@ -35,32 +62,33 @@
|
||||
- Implement more accurate guessing on the PID with the `/proc` integration (via [#219](https://github.com/tale/headplane/pull/219)).
|
||||
- Usernames will now correctly fall back to emails if not provided (via [#257](https://github.com/tale/headplane/pull/257)).
|
||||
- Configuration loading via paths is now supported for sensitive values (via [#283](https://github.com/tale/headplane/pulls/283))
|
||||
- Options like `server.cookie_secret_path` can override `server.cookie_secret`
|
||||
- Environment variables are interpolatable into these paths
|
||||
- See the full reference in the [docs](https://github.com/tale/headplane/blob/main/docs/Configuration.md#sensitive-values)
|
||||
- Options like `server.cookie_secret_path` can override `server.cookie_secret`
|
||||
- Environment variables are interpolatable into these paths
|
||||
- See the full reference in the [docs](https://github.com/tale/headplane/blob/main/docs/Configuration.md#sensitive-values)
|
||||
- The nix overlay build is fixed for the SSH module (via [#282](https://github.com/tale/headplane/pull/282))
|
||||
- Switch our build processes to use TypeScript Go and Rolldown Vite for better build and type-check performance.
|
||||
- Cookies are now encrypted JWTs, preserving API key secrets (*GHSA-wrqq-v7qw-r5w7*)
|
||||
- Cookies are now encrypted JWTs, preserving API key secrets (_GHSA-wrqq-v7qw-r5w7_)
|
||||
- OIDC profile pictures are now available from Gravatar by setting `oidc.profile_picture_source` to `gravatar` (closes [#232](https://github.com/tale/headplane/issues/232)).
|
||||
- OIDC now allows passing many custom parameters:
|
||||
- `oidc.authorization_endpoint`, `oidc.token_endpoint`, and `oidc.userinfo_endpoint` can be overridden to support non-standard providers or scenarios without discovery (closes [#117](https://github.com/tale/headplane/issues/117)).
|
||||
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
|
||||
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
|
||||
- `oidc.authorization_endpoint`, `oidc.token_endpoint`, and `oidc.userinfo_endpoint` can be overridden to support non-standard providers or scenarios without discovery (closes [#117](https://github.com/tale/headplane/issues/117)).
|
||||
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
|
||||
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
|
||||
|
||||
---
|
||||
|
||||
# 0.6.0 (May 25, 2025)
|
||||
|
||||
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
- 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.
|
||||
- Changes made will modify the `oidc.allowed_{domains,groups,users}` fields in the Headscale config file.
|
||||
- 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.
|
||||
- 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 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.
|
||||
- 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.
|
||||
- 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 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)).
|
||||
@@ -77,11 +105,13 @@
|
||||
---
|
||||
|
||||
# 0.5.10 (April 4, 2025)
|
||||
|
||||
- Fix an issue where other preferences to skip onboarding affected every user.
|
||||
|
||||
---
|
||||
|
||||
# 0.5.9 (April 3, 2025)
|
||||
|
||||
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
|
||||
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
|
||||
- Suppress hydration errors for any fields with a date in it.
|
||||
@@ -89,6 +119,7 @@
|
||||
---
|
||||
|
||||
# 0.5.8 (April 3, 2025)
|
||||
|
||||
- You can now skip the onboarding page if desired.
|
||||
- Added the UI to change user roles in the dashboard.
|
||||
- Fixed an issue where integrations would throw instead of loading properly.
|
||||
@@ -99,6 +130,7 @@
|
||||
---
|
||||
|
||||
# 0.5.7 (April 2, 2025)
|
||||
|
||||
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
|
||||
|
||||
---
|
||||
@@ -106,9 +138,10 @@
|
||||
# 0.5.6 (April 2, 2025)
|
||||
|
||||
### IMPORTANT
|
||||
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously *ANY* accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
|
||||
|
||||
Implemented *proper* authentication methods for OIDC.
|
||||
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously _ANY_ accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
|
||||
|
||||
Implemented _proper_ authentication methods for OIDC.
|
||||
This is a large update and copies the permission system from Tailscale.
|
||||
Permissions are not automatically derived from OIDC, but they can be configured via the UI.
|
||||
Additionally, certain roles give certain capabilities, limiting access to parts of the dashboard.
|
||||
@@ -116,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.
|
||||
|
||||
**Changes**:
|
||||
|
||||
- Switched the internal server to use `hono` for better performance.
|
||||
- Fixed an issue that caused dialogs to randomly refocus every 3 seconds.
|
||||
- Headplane will not send API requests when the tab is not focused.
|
||||
@@ -126,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)
|
||||
|
||||
- Hotfix an issue that caused Headplane to crash if no agents are available
|
||||
|
||||
---
|
||||
|
||||
# 0.5.4 (March 18, 2025)
|
||||
|
||||
- Fixed a typo in the Kubernetes documentation
|
||||
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
|
||||
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
|
||||
@@ -147,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)
|
||||
|
||||
- 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))
|
||||
- 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))
|
||||
@@ -154,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)
|
||||
|
||||
- Hotfixed an issue where the server bundle got reloaded on each request
|
||||
|
||||
---
|
||||
|
||||
# 0.5.1 (February 28, 2025)
|
||||
|
||||
- Fixed an issue that caused the entire server to crash on start
|
||||
- Fixed the published semver tags from Docker
|
||||
- Fixed the Kubernetes integration not reading the config
|
||||
@@ -166,6 +205,7 @@ If there are no users, the first user will be given an `owner` role which cannot
|
||||
---
|
||||
|
||||
# 0.5 (February 27, 2025)
|
||||
|
||||
> 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.
|
||||
> Please see [config.example.yaml](/config.example.yaml) for the new format.
|
||||
@@ -183,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)
|
||||
|
||||
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
|
||||
|
||||
---
|
||||
|
||||
# 0.4.0 (January 18, 2025)
|
||||
|
||||
- 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 the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
|
||||
@@ -203,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)
|
||||
|
||||
- Fixed a race condition bug in the OIDC validation code
|
||||
|
||||
---
|
||||
|
||||
# 0.3.8 (December 6, 2024)
|
||||
|
||||
- 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))
|
||||
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
|
||||
@@ -215,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)
|
||||
|
||||
- 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))
|
||||
- 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))
|
||||
@@ -223,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)
|
||||
|
||||
- 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))
|
||||
|
||||
---
|
||||
|
||||
# 0.3.5 (November 8, 2024)
|
||||
|
||||
- Quickfix a bug where environment variables are ignored on the server.
|
||||
- Remove a nagging error about missing cookie since that happens when signed out.
|
||||
|
||||
---
|
||||
|
||||
# 0.3.4 (November 7, 2024)
|
||||
|
||||
- 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.
|
||||
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
|
||||
@@ -247,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)
|
||||
|
||||
- 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
|
||||
- Improved ACL editor performance by switching back to CodeMirror 6
|
||||
@@ -256,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)
|
||||
|
||||
- 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))
|
||||
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
|
||||
@@ -265,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)
|
||||
|
||||
- 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.
|
||||
- Added the option for debug logs with the `DEBUG=true` environment variable.
|
||||
@@ -272,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)
|
||||
|
||||
- Bumped the minimum supported version of Headscale to 0.23.
|
||||
- Updated the UI to respect `dns.use_username_in_magic_dns`.
|
||||
|
||||
---
|
||||
|
||||
# 0.2.4 (August 24, 2024)
|
||||
|
||||
- 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.
|
||||
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
|
||||
@@ -286,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)
|
||||
|
||||
- Change the minimum required version of Headscale to 0.23-beta2
|
||||
- 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))
|
||||
@@ -294,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)
|
||||
|
||||
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
|
||||
- Added a new logger utility that shows categories, levels, and timestamps.
|
||||
- Reimplemented the integration system to be more resilient and log more information.
|
||||
@@ -302,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)
|
||||
|
||||
- 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.
|
||||
- Fixed a bug where the ACL editor did not show the diffs correctly.
|
||||
@@ -310,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)
|
||||
|
||||
- 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).
|
||||
|
||||
---
|
||||
|
||||
# 0.1.9 (June 2, 2024)
|
||||
|
||||
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
|
||||
- Utilize magic DNS hostnames for the machine overview page.
|
||||
- Fixed the expiry issue once and for all.
|
||||
@@ -324,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)
|
||||
|
||||
- Built basic functionality for the machine overview page (by machine ID).
|
||||
- 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.
|
||||
@@ -332,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)
|
||||
|
||||
- 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.
|
||||
- Made the strict configuration loader more lenient to allow for more flexibility.
|
||||
@@ -342,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)
|
||||
|
||||
- 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 `ROOT_API_KEY` was not respected in the OIDC flow.
|
||||
@@ -349,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)
|
||||
|
||||
- Robust configuration handling with fallbacks based on the headscale source.
|
||||
- Support for `client_secret_path` on configuration file based OIDC.
|
||||
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
|
||||
|
||||
+14
-3
@@ -11,14 +11,16 @@ ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG 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 \
|
||||
--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_agent
|
||||
RUN chmod +x /bin/fake-sh
|
||||
RUN chmod +x /bin/hp_healthcheck
|
||||
|
||||
# Folder needs to exist for later stages
|
||||
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
|
||||
|
||||
COPY . .
|
||||
RUN ./build.sh --app
|
||||
ARG HEADPLANE_VERSION
|
||||
RUN HEADPLANE_VERSION=$HEADPLANE_VERSION ./build.sh --app
|
||||
|
||||
FROM gcr.io/distroless/nodejs22-debian12:latest AS final
|
||||
COPY --from=js-base /run/build /app/build
|
||||
@@ -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/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
|
||||
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 /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
|
||||
CMD [ "node", "/app/build/server/index.js" ]
|
||||
|
||||
@@ -107,6 +107,11 @@ function Panel(props: DialogPanelProps) {
|
||||
return (
|
||||
<Form
|
||||
{...dialogProps}
|
||||
className={cn(
|
||||
'outline-hidden rounded-3xl w-full max-w-lg',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
)}
|
||||
method={method ?? 'POST'}
|
||||
onSubmit={(event) => {
|
||||
if (onSubmit) {
|
||||
onSubmit(event);
|
||||
@@ -114,12 +119,7 @@ function Panel(props: DialogPanelProps) {
|
||||
|
||||
close?.();
|
||||
}}
|
||||
method={method ?? 'POST'}
|
||||
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">
|
||||
{children}
|
||||
@@ -130,9 +130,9 @@ function Panel(props: DialogPanelProps) {
|
||||
<>
|
||||
<Button onPress={close}>Cancel</Button>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
variant={variant === 'destructive' ? 'danger' : 'heavy'}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
Confirm
|
||||
</Button>
|
||||
|
||||
@@ -8,11 +8,18 @@ export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
|
||||
labelHidden?: boolean;
|
||||
isRequired?: boolean;
|
||||
className?: string;
|
||||
isInvalid?: boolean;
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
// TODO: Custom isInvalid logic for custom error messages
|
||||
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 id = useId(props.id);
|
||||
|
||||
@@ -21,7 +28,7 @@ export default function Input(props: InputProps) {
|
||||
inputProps,
|
||||
descriptionProps,
|
||||
errorMessageProps,
|
||||
isInvalid,
|
||||
isInvalid: ariaIsInvalid,
|
||||
validationErrors,
|
||||
} = useTextField(
|
||||
{
|
||||
@@ -32,16 +39,18 @@ export default function Input(props: InputProps) {
|
||||
ref,
|
||||
);
|
||||
|
||||
const isInvalid = customIsInvalid ?? ariaIsInvalid;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full" aria-label={label}>
|
||||
<div className="flex flex-col w-full">
|
||||
<label
|
||||
{...labelProps}
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
'text-xs font-medium px-3 mb-0.5',
|
||||
'text-headplane-700 dark:text-headplane-100',
|
||||
labelHidden && 'sr-only',
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
{label}
|
||||
{props.isRequired && (
|
||||
@@ -50,8 +59,6 @@ export default function Input(props: InputProps) {
|
||||
</label>
|
||||
<input
|
||||
{...inputProps}
|
||||
required={props.isRequired}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-xl px-3 py-2',
|
||||
'focus:outline-hidden focus:ring-3',
|
||||
@@ -59,6 +66,8 @@ export default function Input(props: InputProps) {
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
required={props.isRequired}
|
||||
/>
|
||||
{props.description && (
|
||||
<div
|
||||
@@ -76,7 +85,7 @@ export default function Input(props: InputProps) {
|
||||
{...errorMessageProps}
|
||||
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
|
||||
>
|
||||
{validationErrors.join(' ')}
|
||||
{errorMessage ?? validationErrors.join(' ')}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -23,15 +23,15 @@ function Tabs({ label, className, ...props }: TabsProps) {
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<div
|
||||
{...tabListProps}
|
||||
ref={ref}
|
||||
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-t border-x',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<Tab key={item.key} item={item} state={state} />
|
||||
<Tab item={item} key={item.key} state={state} />
|
||||
))}
|
||||
</div>
|
||||
<TabsPanel key={state.selectedItem?.key} state={state} />
|
||||
@@ -52,7 +52,6 @@ function Tab({ item, state }: TabsTabProps) {
|
||||
return (
|
||||
<div
|
||||
{...tabProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'pl-2 pr-3 py-2.5',
|
||||
'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',
|
||||
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{rendered}
|
||||
</div>
|
||||
@@ -76,11 +76,11 @@ function TabsPanel({ state, ...props }: TabsPanelProps) {
|
||||
return (
|
||||
<div
|
||||
{...tabPanelProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'w-full overflow-clip rounded-b-xl rounded-r-xl',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{state.selectedItem?.props.children}
|
||||
</div>
|
||||
|
||||
@@ -18,15 +18,26 @@ export function getErrorMessage(error: Error | unknown): {
|
||||
const { statusCode, rawData, data, requestUrl } = error.data;
|
||||
if (statusCode >= 500) {
|
||||
return {
|
||||
title: 'Cannot connect to Headscale API',
|
||||
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>
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -124,7 +135,7 @@ export function getErrorMessage(error: Error | unknown): {
|
||||
<br />
|
||||
Status Code: <strong>{error.status}</strong>
|
||||
<br />
|
||||
Status Text: <strong>{error.statusText}</strong>
|
||||
Status Text: <strong>{error.data}</strong>
|
||||
</>
|
||||
),
|
||||
};
|
||||
@@ -159,12 +170,10 @@ export function getErrorMessage(error: Error | unknown): {
|
||||
|
||||
// Traverse the error chain to find the root cause
|
||||
let rootError = error;
|
||||
console.log('error', error.cause != null);
|
||||
if (error.cause != null) {
|
||||
rootError = error.cause as Error;
|
||||
while (rootError.cause != null) {
|
||||
rootError = rootError.cause as Error;
|
||||
console.log('setting rootError', rootError.message);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+54
-56
@@ -1,67 +1,65 @@
|
||||
import { PassThrough } from 'node:stream';
|
||||
import { createReadableStreamFromReadable } from '@react-router/node';
|
||||
import { isbot } from 'isbot';
|
||||
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
|
||||
import { renderToPipeableStream } from 'react-dom/server';
|
||||
import type { AppLoadContext, EntryContext } from 'react-router';
|
||||
import { ServerRouter } from 'react-router';
|
||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||
import type { AppLoadContext, EntryContext } from "react-router";
|
||||
|
||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { isbot } from "isbot";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import { ServerRouter } from "react-router";
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
export default function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
loadContext: AppLoadContext,
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
_loadContext: AppLoadContext,
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const userAgent = request.headers.get('user-agent');
|
||||
return new Promise((resolve, reject) => {
|
||||
let shellRendered = false;
|
||||
const userAgent = request.headers.get("user-agent");
|
||||
|
||||
// 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
|
||||
const readyOption: keyof RenderToPipeableStreamOptions =
|
||||
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
|
||||
? 'onAllReady'
|
||||
: 'onShellReady';
|
||||
// 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
|
||||
const readyOption: keyof RenderToPipeableStreamOptions =
|
||||
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? "onAllReady" : "onShellReady";
|
||||
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<ServerRouter context={routerContext} url={request.url} />,
|
||||
{
|
||||
[readyOption]() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
const { pipe, abort } = renderToPipeableStream(
|
||||
<ServerRouter context={routerContext} url={request.url} />,
|
||||
{
|
||||
[readyOption]() {
|
||||
shellRendered = true;
|
||||
const body = new PassThrough();
|
||||
const stream = createReadableStreamFromReadable(body);
|
||||
|
||||
responseHeaders.set('Content-Type', 'text/html');
|
||||
responseHeaders.set("Content-Type", "text/html");
|
||||
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
);
|
||||
resolve(
|
||||
new Response(stream, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
}),
|
||||
);
|
||||
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
// biome-ignore lint/style/noParameterAssign: Lazy
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
pipe(body);
|
||||
},
|
||||
onShellError(error: unknown) {
|
||||
reject(error);
|
||||
},
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Abort the rendering stream after the `streamTimeout` so it has tine to
|
||||
// flush down the rejected boundaries
|
||||
setTimeout(abort, streamTimeout + 1000);
|
||||
});
|
||||
// Abort the rendering stream after the `streamTimeout` so it has tine to
|
||||
// flush down the rejected boundaries
|
||||
setTimeout(abort, streamTimeout + 1000);
|
||||
});
|
||||
}
|
||||
|
||||
+43
-42
@@ -1,53 +1,54 @@
|
||||
import { Outlet, redirect } from 'react-router';
|
||||
import { ErrorBanner } from '~/components/error-banner';
|
||||
import { pruneEphemeralNodes } from '~/server/db/pruner';
|
||||
import { isDataUnauthorizedError } from '~/server/headscale/api/error-client';
|
||||
import log from '~/utils/log';
|
||||
import type { Route } from './+types/dashboard';
|
||||
import { Outlet, redirect } from "react-router";
|
||||
|
||||
import { ErrorBanner } from "~/components/error-banner";
|
||||
import { pruneEphemeralNodes } from "~/server/db/pruner";
|
||||
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "./+types/dashboard";
|
||||
|
||||
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const principal = await context.auth.require(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
// MARK: The session should stay valid if Headscale isn't healthy
|
||||
const healthy = await api.isHealthy();
|
||||
if (healthy) {
|
||||
try {
|
||||
await api.getApiKeys();
|
||||
await pruneEphemeralNodes({ context, request, ...rest });
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
log.warn(
|
||||
'auth',
|
||||
'Logging out %s due to expired API key',
|
||||
session.user.name,
|
||||
);
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
// MARK: The session should stay valid if Headscale isn't healthy
|
||||
const healthy = await api.isHealthy();
|
||||
if (healthy) {
|
||||
try {
|
||||
await api.getApiKeys();
|
||||
await pruneEphemeralNodes({ context, request, ...rest });
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
const displayName =
|
||||
principal.kind === "oidc" ? principal.profile.name : principal.displayName;
|
||||
log.warn("auth", "Logging out %s due to expired API key", displayName);
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
healthy,
|
||||
};
|
||||
return {
|
||||
healthy,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
return (
|
||||
<main className="container mx-auto overscroll-contain mt-4 mb-24">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
return (
|
||||
<main className="container mx-auto mt-4 mb-24 overscroll-contain">
|
||||
<Outlet />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div className="w-fit mx-auto overscroll-contain my-24">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="mx-auto my-24 w-fit overscroll-contain">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+296
-99
@@ -1,110 +1,307 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { CircleCheckIcon } from 'lucide-react';
|
||||
import { Outlet, redirect } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Footer from '~/components/Footer';
|
||||
import Header from '~/components/Header';
|
||||
import { users } from '~/server/db/schema';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import toast from '~/utils/toast';
|
||||
import { Route } from './+types/shell';
|
||||
import { Icon } from "@iconify/react";
|
||||
import { Form, Outlet, redirect } from "react-router";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Card from "~/components/Card";
|
||||
import Footer from "~/components/Footer";
|
||||
import Header from "~/components/Header";
|
||||
import Link from "~/components/Link";
|
||||
import Options from "~/components/Options";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import toast from "~/utils/toast";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
import { Route } from "./+types/shell";
|
||||
|
||||
// This loads the bare minimum for the application to function
|
||||
// So we know that if context fails to load then well, oops?
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (
|
||||
typeof context.oidc === 'object' &&
|
||||
session.user.subject !== 'unknown-non-oauth' &&
|
||||
!request.url.endsWith('/onboarding')
|
||||
) {
|
||||
const [user] = await context.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, session.user.subject))
|
||||
.limit(1);
|
||||
try {
|
||||
const principal = await context.auth.require(request);
|
||||
|
||||
if (!user?.onboarded) {
|
||||
return redirect('/onboarding');
|
||||
}
|
||||
}
|
||||
if (
|
||||
typeof context.oidc === "object" &&
|
||||
principal.kind === "oidc" &&
|
||||
!principal.user.onboarded &&
|
||||
!request.url.endsWith("/onboarding")
|
||||
) {
|
||||
return redirect("/onboarding");
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const check = await context.sessions.check(request, Capabilities.ui_access);
|
||||
return {
|
||||
config: context.hs.c,
|
||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
configAvailable: context.hs.readable(),
|
||||
debug: context.config.debug,
|
||||
user: session.user,
|
||||
uiAccess: check,
|
||||
access: {
|
||||
ui: await context.sessions.check(request, Capabilities.ui_access),
|
||||
dns: await context.sessions.check(request, Capabilities.read_network),
|
||||
users: await context.sessions.check(request, Capabilities.read_users),
|
||||
policy: await context.sessions.check(request, Capabilities.read_policy),
|
||||
machines: await context.sessions.check(
|
||||
request,
|
||||
Capabilities.read_machines,
|
||||
),
|
||||
settings: await context.sessions.check(
|
||||
request,
|
||||
Capabilities.read_feature,
|
||||
),
|
||||
},
|
||||
onboarding: request.url.endsWith('/onboarding'),
|
||||
healthy: await api.isHealthy(),
|
||||
};
|
||||
} catch {
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
}
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const check = context.auth.can(principal, Capabilities.ui_access);
|
||||
const noAccess = !check && principal.kind === "oidc";
|
||||
|
||||
const user =
|
||||
principal.kind === "oidc"
|
||||
? {
|
||||
subject: principal.user.subject,
|
||||
name: principal.profile.name,
|
||||
email: principal.profile.email,
|
||||
username: principal.profile.username,
|
||||
picture: principal.profile.picture,
|
||||
}
|
||||
: { subject: "api_key", name: principal.displayName };
|
||||
|
||||
let linkedUserName: string | undefined;
|
||||
let osValue: string | undefined;
|
||||
|
||||
if (noAccess && principal.kind === "oidc") {
|
||||
const hsUserId = principal.user.headscaleUserId;
|
||||
if (hsUserId) {
|
||||
try {
|
||||
const apiUsers = await api.getUsers();
|
||||
const hsUser = apiUsers.find((u) => u.id === hsUserId);
|
||||
linkedUserName = hsUser ? getUserDisplayName(hsUser) : undefined;
|
||||
} catch {
|
||||
// API unavailable, skip linked user resolution
|
||||
}
|
||||
}
|
||||
|
||||
const userAgent = request.headers.get("user-agent");
|
||||
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
|
||||
switch (os?.[0]) {
|
||||
case "Windows":
|
||||
osValue = "windows";
|
||||
break;
|
||||
case "Mac OS X":
|
||||
osValue = "macos";
|
||||
break;
|
||||
case "iPhone":
|
||||
case "iPad":
|
||||
osValue = "ios";
|
||||
break;
|
||||
case "Android":
|
||||
osValue = "android";
|
||||
break;
|
||||
default:
|
||||
osValue = "linux";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config: context.hs.c,
|
||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
configAvailable: context.hs.readable(),
|
||||
debug: context.config.debug,
|
||||
user,
|
||||
access: {
|
||||
ui: check,
|
||||
dns: context.auth.can(principal, Capabilities.read_network),
|
||||
users: context.auth.can(principal, Capabilities.read_users),
|
||||
policy: context.auth.can(principal, Capabilities.read_policy),
|
||||
machines: context.auth.can(principal, Capabilities.read_machines),
|
||||
settings: context.auth.can(principal, Capabilities.read_feature),
|
||||
},
|
||||
onboarding: request.url.endsWith("/onboarding"),
|
||||
noAccess,
|
||||
linkedUserName,
|
||||
osValue,
|
||||
healthy: await api.isHealthy(),
|
||||
};
|
||||
} catch {
|
||||
return redirect("/login", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default function Shell({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<Header {...loaderData} />
|
||||
{/* Always show the outlet if we are onboarding */}
|
||||
{(loaderData.onboarding ? true : loaderData.uiAccess) ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<Card className="mx-auto w-fit mt-24">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-3xl mb-0">Connected</Card.Title>
|
||||
<CircleCheckIcon className="w-10 h-10" />
|
||||
</div>
|
||||
<Card.Text className="my-4 text-lg">
|
||||
Connect to Tailscale with your devices to access this Tailnet. Use
|
||||
this command to help you get started:
|
||||
</Card.Text>
|
||||
<Button
|
||||
className="flex text-md font-mono"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`tailscale up --login-server=${loaderData.url}`,
|
||||
);
|
||||
if (loaderData.noAccess && !loaderData.onboarding) {
|
||||
return (
|
||||
<>
|
||||
<Header {...loaderData} />
|
||||
<main className="container mx-auto mt-4 mb-24 overscroll-contain">
|
||||
<div className="mx-auto mt-12 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{loaderData.linkedUserName ? (
|
||||
<Card className="col-span-1 mx-auto max-w-lg md:col-span-2" variant="flat">
|
||||
<p className="text-sm">
|
||||
✓ Your account is linked to Headscale user{" "}
|
||||
<strong>{loaderData.linkedUserName}</strong>.
|
||||
</p>
|
||||
</Card>
|
||||
) : undefined}
|
||||
<Card className="max-w-lg" variant="flat">
|
||||
<Card.Title className="mb-8">
|
||||
Access your network
|
||||
<br />
|
||||
via Tailscale
|
||||
</Card.Title>
|
||||
<Card.Text>
|
||||
You don't have dashboard access, but you can still connect to your Headscale
|
||||
network. Install Tailscale on your device to get started.
|
||||
</Card.Text>
|
||||
|
||||
toast('Copied to clipboard');
|
||||
}}
|
||||
>
|
||||
tailscale up --login-server={loaderData.url}
|
||||
</Button>
|
||||
<p className="text-xs mt-1 opacity-50 text-center">
|
||||
Click this button to copy the command.
|
||||
</p>
|
||||
<p className="mt-4 text-sm opacity-50">
|
||||
Your account does not have access to the UI. Please contact your
|
||||
administrator if you believe this is a mistake.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
<Footer {...loaderData} />
|
||||
</>
|
||||
);
|
||||
<Options
|
||||
className="my-4"
|
||||
defaultSelectedKey={loaderData.osValue ?? "linux"}
|
||||
label="Download Selector"
|
||||
>
|
||||
<Options.Item
|
||||
key="linux"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="ion:terminal" />
|
||||
<span>Linux</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="text-md flex font-mono"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
"curl -fsSL https://tailscale.com/install.sh | sh",
|
||||
);
|
||||
toast("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
</Button>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 mt-1 text-center text-xs">
|
||||
Click this button to copy the command.{" "}
|
||||
<Link
|
||||
name="Linux installation script"
|
||||
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
|
||||
>
|
||||
View script source
|
||||
</Link>
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="windows"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
|
||||
<span>Windows</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for Windows"
|
||||
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Windows
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires Windows 10 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="macos"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="streamline-logos:mac-finder-logo-solid" />
|
||||
<span>macOS</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for macOS"
|
||||
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for macOS
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires macOS Big Sur 11.0 or later.
|
||||
<br />
|
||||
You can also download Tailscale on the{" "}
|
||||
<Link
|
||||
name="macOS App Store"
|
||||
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
|
||||
>
|
||||
macOS App Store
|
||||
</Link>
|
||||
{"."}
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="ios"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
|
||||
<span>iOS</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for iOS"
|
||||
href="https://apps.apple.com/us/app/tailscale/id1470499037"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for iOS
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires iOS 15 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="android"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="material-symbols:android" />
|
||||
<span>Android</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for Android"
|
||||
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Android
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires Android 8 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
</Options>
|
||||
</Card>
|
||||
<Card className="max-w-lg" variant="flat">
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<div>
|
||||
<Card.Title className="mb-4">Need dashboard access?</Card.Title>
|
||||
<Card.Text>
|
||||
Your account is signed in but doesn't have permission to manage the dashboard.
|
||||
Contact an administrator to request access.
|
||||
</Card.Text>
|
||||
</div>
|
||||
<Form action="/logout" className="mt-6" method="POST">
|
||||
<Button className="w-full" type="submit" variant="light">
|
||||
Sign out
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</main>
|
||||
<Footer {...loaderData} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header {...loaderData} />
|
||||
<Outlet />
|
||||
<Footer {...loaderData} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"0.26.1": ["0.26.0", "0.26.1"],
|
||||
"0.27.0": ["0.27.0"],
|
||||
"0.27.1": ["0.27.1"]
|
||||
"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"]
|
||||
}
|
||||
|
||||
+133
-106
@@ -1,108 +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.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"
|
||||
}
|
||||
}
|
||||
|
||||
+56
-62
@@ -1,77 +1,71 @@
|
||||
import type { LinksFunction, MetaFunction } from 'react-router';
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useNavigation,
|
||||
} from 'react-router';
|
||||
import '@fontsource-variable/inter';
|
||||
import { ExternalScripts } from 'remix-utils/external-scripts';
|
||||
import ProgressBar from '~/components/ProgressBar';
|
||||
import ToastProvider from '~/components/ToastProvider';
|
||||
import stylesheet from '~/tailwind.css?url';
|
||||
import { LiveDataProvider } from '~/utils/live-data';
|
||||
import { useToastQueue } from '~/utils/toast';
|
||||
import type { Route } from './+types/root';
|
||||
import { ErrorBanner } from './components/error-banner';
|
||||
import type { LinksFunction, MetaFunction } from "react-router";
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useNavigation } from "react-router";
|
||||
import "@fontsource-variable/inter";
|
||||
import { ExternalScripts } from "remix-utils/external-scripts";
|
||||
|
||||
import ProgressBar from "~/components/ProgressBar";
|
||||
import ToastProvider from "~/components/ToastProvider";
|
||||
import { LiveDataProvider } from "~/utils/live-data";
|
||||
import { useToastQueue } from "~/utils/toast";
|
||||
|
||||
import type { Route } from "./+types/root";
|
||||
import { ErrorBanner } from "./components/error-banner";
|
||||
|
||||
import stylesheet from "~/tailwind.css?url";
|
||||
|
||||
export const meta: MetaFunction = () => [
|
||||
{ title: 'Headplane' },
|
||||
{
|
||||
name: 'description',
|
||||
content: 'A frontend for the headscale coordination server',
|
||||
},
|
||||
{ title: "Headplane" },
|
||||
{
|
||||
name: "description",
|
||||
content: "A frontend for the headscale coordination server",
|
||||
},
|
||||
];
|
||||
|
||||
export const links: LinksFunction = () => [
|
||||
{ rel: 'stylesheet', href: stylesheet },
|
||||
];
|
||||
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
|
||||
|
||||
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
const toastQueue = useToastQueue();
|
||||
const toastQueue = useToastQueue();
|
||||
|
||||
// LiveDataProvider is wrapped at the top level since dialogs and things
|
||||
// that control its state are usually open in portal containers which
|
||||
// are not a part of the normal React tree.
|
||||
return (
|
||||
<LiveDataProvider>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<Meta />
|
||||
<Links />
|
||||
<link href="favicon.ico" rel="icon" />
|
||||
</head>
|
||||
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
|
||||
{children}
|
||||
<ToastProvider queue={toastQueue} />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
<ExternalScripts />
|
||||
</body>
|
||||
</html>
|
||||
</LiveDataProvider>
|
||||
);
|
||||
// LiveDataProvider is wrapped at the top level since dialogs and things
|
||||
// that control its state are usually open in portal containers which
|
||||
// are not a part of the normal React tree.
|
||||
return (
|
||||
<LiveDataProvider>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<Meta />
|
||||
<Links />
|
||||
<link href={`${__PREFIX__}/favicon.ico`} rel="icon" />
|
||||
</head>
|
||||
<body className="dark:bg-headplane-900 dark:text-headplane-50 overflow-x-hidden overscroll-none">
|
||||
{children}
|
||||
<ToastProvider queue={toastQueue} />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
<ExternalScripts />
|
||||
</body>
|
||||
</html>
|
||||
</LiveDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div className="w-screen h-screen flex items-center justify-center p-4">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center p-4">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const nav = useNavigation();
|
||||
const nav = useNavigation();
|
||||
|
||||
return (
|
||||
<>
|
||||
<ProgressBar isVisible={nav.state === 'loading'} />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<ProgressBar isVisible={nav.state === "loading"} />
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+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 [
|
||||
// Utility Routes
|
||||
index('routes/util/redirect.ts'),
|
||||
route('/healthz', 'routes/util/healthz.ts'),
|
||||
// Utility Routes
|
||||
index("routes/util/redirect.ts"),
|
||||
route("/healthz", "routes/util/healthz.ts"),
|
||||
|
||||
// Authentication Routes
|
||||
route('/login', 'routes/auth/login/page.tsx'),
|
||||
route('/logout', 'routes/auth/logout.ts'),
|
||||
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
|
||||
route('/oidc/start', 'routes/auth/oidc-start.ts'),
|
||||
route('/ssh', 'routes/ssh/console.tsx'),
|
||||
// API Routes
|
||||
...prefix("/api", [route("/info", "routes/util/info.ts")]),
|
||||
|
||||
// All the main logged-in dashboard routes
|
||||
// Double nested to separate error propagations
|
||||
layout('layouts/shell.tsx', [
|
||||
route('/onboarding', 'routes/users/onboarding.tsx'),
|
||||
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'),
|
||||
layout('layouts/dashboard.tsx', [
|
||||
...prefix('/machines', [
|
||||
index('routes/machines/overview.tsx'),
|
||||
route('/:id', 'routes/machines/machine.tsx'),
|
||||
]),
|
||||
// Authentication Routes
|
||||
route("/login", "routes/auth/login/page.tsx"),
|
||||
route("/logout", "routes/auth/logout.ts"),
|
||||
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
|
||||
route("/oidc/start", "routes/auth/oidc-start.ts"),
|
||||
route("/ssh", "routes/ssh/console.tsx"),
|
||||
|
||||
route('/users', 'routes/users/overview.tsx'),
|
||||
route('/acls', 'routes/acls/overview.tsx'),
|
||||
route('/dns', 'routes/dns/overview.tsx'),
|
||||
// All the main logged-in dashboard routes
|
||||
// Double nested to separate error propagations
|
||||
layout("layouts/shell.tsx", [
|
||||
route("/onboarding", "routes/users/onboarding.tsx"),
|
||||
route("/onboarding/skip", "routes/users/onboarding-skip.tsx"),
|
||||
layout("layouts/dashboard.tsx", [
|
||||
...prefix("/machines", [
|
||||
index("routes/machines/overview.tsx"),
|
||||
route("/:id", "routes/machines/machine.tsx"),
|
||||
]),
|
||||
|
||||
...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'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
route("/users", "routes/users/overview.tsx"),
|
||||
route("/acls", "routes/acls/overview.tsx"),
|
||||
route("/dns", "routes/dns/overview.tsx"),
|
||||
|
||||
...prefix("/settings", [
|
||||
index("routes/settings/overview.tsx"),
|
||||
route("/auth-keys", "routes/settings/auth-keys/overview.tsx"),
|
||||
route("/restrictions", "routes/settings/restrictions/overview.tsx"),
|
||||
// route('/local-agent', 'routes/settings/local-agent.tsx'),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
];
|
||||
|
||||
+115
-94
@@ -1,108 +1,129 @@
|
||||
import { data } from 'react-router';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
import { data } from "react-router";
|
||||
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
// We only check capabilities here and assume it is writable
|
||||
// If it isn't, it'll gracefully error anyways, since this means some
|
||||
// fishy client manipulation is happening.
|
||||
export async function aclAction({ request, context }: Route.ActionArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_policy,
|
||||
);
|
||||
if (!check) {
|
||||
throw data('You do not have permission to write to the ACL policy', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.write_policy);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to write to the ACL policy", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
// Try to write to the ACL policy via the API or via config file (TODO).
|
||||
const formData = await request.formData();
|
||||
const policyData = formData.get('policy')?.toString();
|
||||
if (!policyData) {
|
||||
throw data('Missing `policy` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
// Try to write to the ACL policy via the API or via config file (TODO).
|
||||
const formData = await request.formData();
|
||||
const policyData = formData.get("policy")?.toString();
|
||||
if (!policyData) {
|
||||
throw data("Missing `policy` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.setPolicy(policyData);
|
||||
return data({
|
||||
success: true,
|
||||
error: undefined,
|
||||
policy,
|
||||
updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
// 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 data('Policy is not writable', { status: 403 });
|
||||
}
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.setPolicy(policyData);
|
||||
return data({
|
||||
success: true,
|
||||
error: undefined,
|
||||
policy,
|
||||
updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
if (isDataWithApiError(error)) {
|
||||
const rawData = error.data.rawData;
|
||||
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
|
||||
if (rawData.includes("update is disabled")) {
|
||||
throw data("Policy is not writable", { status: 403 });
|
||||
}
|
||||
|
||||
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
|
||||
if (message.includes('parsing hujson')) {
|
||||
// This means the policy was invalid, return a 400
|
||||
// with the actual error message from Headscale
|
||||
const cutIndex = message.indexOf('err: hujson:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 12)}`
|
||||
: message;
|
||||
const message =
|
||||
error.data.data != null &&
|
||||
"message" in error.data.data &&
|
||||
typeof error.data.data.message === "string"
|
||||
? error.data.data.message
|
||||
: undefined;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (message == null) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (message.includes('unmarshalling policy')) {
|
||||
// This means the policy was invalid, return a 400
|
||||
// with the actual error message from Headscale
|
||||
const cutIndex = message.indexOf('err:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 5)}`
|
||||
: message;
|
||||
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
|
||||
// we need to reference other error messages based on API version.
|
||||
if (context.hsApi.clientHelpers.isAtleast("0.27.0")) {
|
||||
if (message.includes("parsing HuJSON:")) {
|
||||
const cutIndex = message.indexOf("parsing HuJSON:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('empty policy')) {
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: 'Policy error: Supplied policy was empty',
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (message.includes("parsing policy from bytes:")) {
|
||||
const cutIndex = message.indexOf("parsing policy from bytes:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
|
||||
|
||||
// Otherwise, this is a Headscale error that we can just propagate.
|
||||
throw error;
|
||||
}
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Pre-0.27.0 error messages
|
||||
if (message.includes("parsing hujson")) {
|
||||
const cutIndex = message.indexOf("err: hujson:");
|
||||
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
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,7 +1,9 @@
|
||||
import { data } from 'react-router';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
import { data } from "react-router";
|
||||
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
// The logic for deciding policy factors is very complicated because
|
||||
// there are so many factors that need to be accounted for:
|
||||
@@ -11,49 +13,39 @@ import type { Route } from './+types/overview';
|
||||
// If database, we can read/write easily via the API.
|
||||
// If in file mode, we can only write if context.config is available.
|
||||
export async function aclLoader({ request, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(request, Capabilities.read_policy);
|
||||
if (!check) {
|
||||
throw data('You do not have permission to read the ACL policy.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.read_policy);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to read the ACL policy.", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const flags = {
|
||||
// Can the user write to the ACL policy
|
||||
access: await context.sessions.check(request, Capabilities.write_policy),
|
||||
writable: false,
|
||||
policy: '',
|
||||
};
|
||||
const flags = {
|
||||
// Can the user write to the ACL policy
|
||||
access: context.auth.can(principal, Capabilities.write_policy),
|
||||
writable: false,
|
||||
policy: "",
|
||||
};
|
||||
|
||||
// Try to load the ACL policy from the API.
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.getPolicy();
|
||||
// Try to load the ACL policy from the API.
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.getPolicy();
|
||||
flags.writable = updatedAt !== null;
|
||||
flags.policy = policy;
|
||||
return flags;
|
||||
} catch (error) {
|
||||
if (isDataWithApiError(error)) {
|
||||
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
|
||||
if (error.data.rawData.includes("acl policy not found")) {
|
||||
flags.policy = "";
|
||||
flags.writable = true;
|
||||
return flags;
|
||||
}
|
||||
}
|
||||
|
||||
// Successfully loaded the policy, mark it as readable
|
||||
// If `updatedAt` is null, it means the policy is in file mode.
|
||||
flags.writable = updatedAt !== null;
|
||||
flags.policy = policy;
|
||||
return flags;
|
||||
} catch (error) {
|
||||
// This means Headscale returned a protobuf error to us
|
||||
// It also means we 100% know this is in database mode
|
||||
if (error instanceof ResponseError && error.responseObject?.message) {
|
||||
const message = error.responseObject.message as string;
|
||||
// This is stupid, refer to the link
|
||||
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
|
||||
if (message.includes('acl policy not found')) {
|
||||
// This means the policy has never been initiated, and we can
|
||||
// write to it to get it started or ignore it.
|
||||
flags.policy = ''; // Start with an empty policy
|
||||
flags.writable = true;
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
// Otherwise, this is a Headscale error that we can just propagate.
|
||||
throw error;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
|
||||
import {
|
||||
AlertCircle,
|
||||
Construction,
|
||||
Eye,
|
||||
FlaskConical,
|
||||
Pencil,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useFetcher, useRevalidator } from 'react-router';
|
||||
import { isRouteErrorResponse, useFetcher, useRevalidator } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import Tabs from '~/components/Tabs';
|
||||
import { isApiError } from '~/server/headscale/api/error-client';
|
||||
import toast from '~/utils/toast';
|
||||
import type { Route } from './+types/overview';
|
||||
import { aclAction } from './acl-action';
|
||||
@@ -164,3 +172,49 @@ export default function Page({
|
||||
</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,98 +1,102 @@
|
||||
import { data, redirect } from 'react-router';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import log from '~/utils/log';
|
||||
import type { Route } from './+types/page';
|
||||
import { redirect } from "react-router";
|
||||
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
|
||||
export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
const formData = await request.formData();
|
||||
const apiKey = formData.has('api_key')
|
||||
? String(formData.get('api_key'))
|
||||
: undefined;
|
||||
const formData = await request.formData();
|
||||
const apiKey = formData.has("api_key") ? String(formData.get("api_key")) : undefined;
|
||||
|
||||
if (apiKey === undefined) {
|
||||
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 === undefined) {
|
||||
log.warn("auth", "Request made without API key");
|
||||
log.warn(
|
||||
"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.",
|
||||
};
|
||||
}
|
||||
|
||||
if (apiKey.length === 0) {
|
||||
log.warn('auth', 'Request made with empty API key');
|
||||
log.warn(
|
||||
'auth',
|
||||
'If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly',
|
||||
);
|
||||
throw data('Received an empty `api_key`', { status: 400 });
|
||||
}
|
||||
if (apiKey.length === 0) {
|
||||
log.warn("auth", "Request made with empty API key");
|
||||
log.warn(
|
||||
"auth",
|
||||
"If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly",
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
message: "API key cannot be empty. Please enter a valid API key.",
|
||||
};
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
try {
|
||||
const apiKeys = await api.getApiKeys();
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
try {
|
||||
const apiKeys = await api.getApiKeys();
|
||||
|
||||
// We don't need to check for 0 API keys because this request cannot
|
||||
// be authenticated correctly without an API key
|
||||
const lookup = apiKeys.find((key) => apiKey.startsWith(key.prefix));
|
||||
if (!lookup) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'API key was not found in the Headscale database',
|
||||
};
|
||||
}
|
||||
// We don't need to check for 0 API keys because this request cannot
|
||||
// be authenticated correctly without an API key
|
||||
//
|
||||
// 0.28.0 pointlessly added asterisks to the prefixes of API keys, which is
|
||||
// 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",
|
||||
};
|
||||
}
|
||||
|
||||
if (lookup.expiration === null || lookup.expiration === undefined) {
|
||||
log.error('auth', 'Got an API key without an expiration');
|
||||
throw data('API key is malformed', { status: 500 });
|
||||
}
|
||||
if (lookup.expiration === null || lookup.expiration === undefined) {
|
||||
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.",
|
||||
};
|
||||
}
|
||||
|
||||
const expiry = new Date(lookup.expiration);
|
||||
if (expiry.getTime() < Date.now()) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'API key has expired',
|
||||
};
|
||||
}
|
||||
const expiry = new Date(lookup.expiration);
|
||||
if (expiry.getTime() < Date.now()) {
|
||||
return {
|
||||
success: false,
|
||||
message: "API key has expired",
|
||||
};
|
||||
}
|
||||
|
||||
const expiresDays = Math.round(
|
||||
(expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24,
|
||||
);
|
||||
return redirect("/machines", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.createApiKeySession(
|
||||
apiKey,
|
||||
`${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)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.createSession(
|
||||
{
|
||||
api_key: apiKey,
|
||||
user: {
|
||||
subject: 'unknown-non-oauth',
|
||||
name: `${lookup.prefix}...`,
|
||||
email: `expires@${expiresDays.toString()}-days`,
|
||||
},
|
||||
},
|
||||
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);
|
||||
log.debug('auth', 'Error details: %o', error);
|
||||
return {
|
||||
success: false,
|
||||
message: 'Error while validating API key (see logs for details)',
|
||||
};
|
||||
}
|
||||
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)",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,123 +1,131 @@
|
||||
import { AlertCircle } 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';
|
||||
import { AlertCircle, CloudOff } from "lucide-react";
|
||||
|
||||
export function OidcConfigErrorNotice({
|
||||
errors,
|
||||
}: {
|
||||
errors: OidcConnectorError[];
|
||||
}) {
|
||||
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">Authentication Error</Card.Title>
|
||||
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
||||
</div>
|
||||
<Card.Text className="text-sm">
|
||||
The OpenID Connect (OIDC) Single Sign-On (SSO) configuration has issues:{' '}
|
||||
<ul className="list-disc list-inside mt-2 mb-1">
|
||||
{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>
|
||||
);
|
||||
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;
|
||||
}[] = [];
|
||||
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;
|
||||
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_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_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_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 "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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
return messages;
|
||||
}
|
||||
|
||||
+135
-145
@@ -1,165 +1,155 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Link as RemixLink,
|
||||
redirect,
|
||||
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 Link from '~/components/Link';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import type { Route } from './+types/page';
|
||||
import { loginAction } from './action';
|
||||
import { OidcConfigErrorNotice } from './config-error';
|
||||
import Logout from './logout';
|
||||
import { OidcErrorNotice } from './oidc-error';
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Form, Link as RemixLink, redirect, 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 Link from "~/components/Link";
|
||||
import { useLiveData } from "~/utils/live-data";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
import { loginAction } from "./action";
|
||||
import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error";
|
||||
import Logout from "./logout";
|
||||
import { OidcErrorNotice } from "./oidc-error";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
await context.sessions.auth(request);
|
||||
return redirect('/machines');
|
||||
} catch {}
|
||||
try {
|
||||
await context.auth.require(request);
|
||||
return redirect("/machines");
|
||||
} catch {}
|
||||
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const urlState = qp.get('s') ?? undefined;
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const urlState = qp.get("s") ?? undefined;
|
||||
|
||||
// MARK: This works because the OIDC connector will always return false
|
||||
// for `isExclusive` if the OIDC config isn't usable.
|
||||
if (context.oidcConnector?.isExclusive && urlState !== 'logout') {
|
||||
return redirect('/oidc/start');
|
||||
}
|
||||
const oidcConnector = await context.oidc?.connector.get();
|
||||
|
||||
const isOidcConnectorEnabled = context.oidcConnector?.isValid;
|
||||
const oidcErrorCodes = !isOidcConnectorEnabled
|
||||
? (context.oidcConnector?.errors ?? [])
|
||||
: [];
|
||||
// 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");
|
||||
}
|
||||
|
||||
return {
|
||||
isCookieSecureEnabled: context.config.server.cookie_secure,
|
||||
isOidcConnectorEnabled,
|
||||
oidcErrorCodes,
|
||||
urlState,
|
||||
};
|
||||
const isOidcConnectorEnabled = oidcConnector?.isValid;
|
||||
const oidcErrorCodes = !isOidcConnectorEnabled ? (oidcConnector?.errors ?? []) : [];
|
||||
|
||||
return {
|
||||
isCookieSecureEnabled: context.config.server.cookie_secure,
|
||||
isOidcConnectorEnabled,
|
||||
oidcErrorCodes,
|
||||
urlState,
|
||||
};
|
||||
}
|
||||
|
||||
export const action = loginAction;
|
||||
|
||||
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
|
||||
const {
|
||||
isCookieSecureEnabled,
|
||||
isOidcConnectorEnabled,
|
||||
oidcErrorCodes,
|
||||
urlState,
|
||||
} = loaderData;
|
||||
const { isCookieSecureEnabled, isOidcConnectorEnabled, oidcErrorCodes, urlState } = loaderData;
|
||||
|
||||
const [showCookieWarning, setShowCookieWarning] = useState(false);
|
||||
const [params] = useSearchParams();
|
||||
const { pause } = useLiveData();
|
||||
const [showCookieWarning, setShowCookieWarning] = useState(false);
|
||||
const [params] = useSearchParams();
|
||||
const { pause } = useLiveData();
|
||||
|
||||
useEffect(() => {
|
||||
// This page does NOT need stale while revalidate logic
|
||||
pause();
|
||||
useEffect(() => {
|
||||
// This page does NOT need stale while revalidate logic
|
||||
pause();
|
||||
|
||||
if (isCookieSecureEnabled && window.location.protocol !== 'https:') {
|
||||
setShowCookieWarning(true);
|
||||
}
|
||||
});
|
||||
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 (urlState !== null) {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
searchParams.delete('s');
|
||||
useEffect(() => {
|
||||
// State is a one time thing, we need to remove it after it has
|
||||
// been consumed to prevent logic loops.
|
||||
if (urlState !== null) {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
searchParams.delete("s");
|
||||
|
||||
// Replacing because it's not a navigation, just a cleanup of the URL
|
||||
// We can't use the useSearchParams method since it revalidates
|
||||
// which will trigger a full reload
|
||||
const newUrl = searchParams.toString()
|
||||
? `{${window.location.pathname}?${searchParams.toString()}`
|
||||
: window.location.pathname;
|
||||
// Replacing because it's not a navigation, just a cleanup of the URL
|
||||
// We can't use the useSearchParams method since it revalidates
|
||||
// which will trigger a full reload
|
||||
const newUrl = searchParams.toString()
|
||||
? `{${window.location.pathname}?${searchParams.toString()}`
|
||||
: window.location.pathname;
|
||||
|
||||
window.history.replaceState(null, '', newUrl);
|
||||
}
|
||||
}, [urlState, params]);
|
||||
window.history.replaceState(null, "", newUrl);
|
||||
}
|
||||
}, [urlState, params]);
|
||||
|
||||
if (urlState === 'logout') {
|
||||
return <Logout />;
|
||||
}
|
||||
if (urlState === "logout") {
|
||||
return <Logout />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-screen h-screen items-center justify-center">
|
||||
<div>
|
||||
{urlState?.startsWith('error_') ? (
|
||||
<OidcErrorNotice code={urlState} />
|
||||
) : oidcErrorCodes.length > 0 ? (
|
||||
<OidcConfigErrorNotice errors={oidcErrorCodes} />
|
||||
) : showCookieWarning ? (
|
||||
<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
|
||||
</Card.Title>
|
||||
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
||||
</div>
|
||||
{showCookieWarning ? (
|
||||
<Card.Text className="text-sm">
|
||||
Headplane is configured to use secure cookies, but this site is
|
||||
being served over an insecure connection and login will not work
|
||||
correctly.{' '}
|
||||
<Link
|
||||
name="Headplane Common Issues"
|
||||
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
|
||||
>
|
||||
Learn more.
|
||||
</Link>
|
||||
</Card.Text>
|
||||
) : undefined}
|
||||
</Card>
|
||||
) : undefined}
|
||||
<Card className="max-w-md m-4 sm:m-0">
|
||||
<Card.Title>Welcome to Headplane</Card.Title>
|
||||
<Form method="POST">
|
||||
<Card.Text>
|
||||
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="text-sm mb-2 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="w-full mt-2"
|
||||
isDisabled={oidcErrorCodes.length > 0}
|
||||
variant="light"
|
||||
>
|
||||
Single Sign-On
|
||||
</Button>
|
||||
</RemixLink>
|
||||
) : undefined}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<div>
|
||||
{urlState?.startsWith("error_") ? (
|
||||
<OidcErrorNotice code={urlState} />
|
||||
) : oidcErrorCodes.includes("DISCOVERY_FAILED") ? (
|
||||
<OidcDiscoveryFailedNotice />
|
||||
) : oidcErrorCodes.length > 0 ? (
|
||||
<OidcConfigErrorNotice errors={oidcErrorCodes} />
|
||||
) : showCookieWarning ? (
|
||||
<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">Configuration Issue</Card.Title>
|
||||
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
{showCookieWarning ? (
|
||||
<Card.Text className="text-sm">
|
||||
Headplane is configured to use secure cookies, but this site is being served over an
|
||||
insecure connection and login will not work correctly.{" "}
|
||||
<Link
|
||||
name="Headplane Common Issues"
|
||||
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
|
||||
>
|
||||
Learn more.
|
||||
</Link>
|
||||
</Card.Text>
|
||||
) : undefined}
|
||||
</Card>
|
||||
) : undefined}
|
||||
<Card className="m-4 max-w-md sm:m-0">
|
||||
<Card.Title>Welcome to Headplane</Card.Title>
|
||||
<Form method="POST">
|
||||
<Card.Text>
|
||||
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 { LoadContext } from '~/server';
|
||||
import { type ActionFunctionArgs, redirect } from "react-router";
|
||||
|
||||
import type { LoadContext } from "~/server";
|
||||
|
||||
export async function loader() {
|
||||
return redirect('/machines');
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
try {
|
||||
await context.sessions.auth(request);
|
||||
} catch {
|
||||
redirect('/login');
|
||||
}
|
||||
export async function action({ request, context }: ActionFunctionArgs<LoadContext>) {
|
||||
try {
|
||||
await context.auth.require(request);
|
||||
} catch {
|
||||
redirect("/login");
|
||||
}
|
||||
|
||||
// When API key is disabled, we need to explicitly redirect
|
||||
// with a logout state to prevent auto login again.
|
||||
const url = context.config.oidc?.disable_api_key_login
|
||||
? '/login?s=logout'
|
||||
: '/login';
|
||||
// When API key is disabled, we need to explicitly redirect
|
||||
// with a logout state to prevent auto login again.
|
||||
const url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
|
||||
|
||||
return redirect(url, {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroySession(),
|
||||
},
|
||||
});
|
||||
return redirect(url, {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.destroySession(request),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+129
-121
@@ -1,140 +1,148 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { count, eq } from 'drizzle-orm';
|
||||
import * as oidc from 'openid-client';
|
||||
import { data, redirect } from 'react-router';
|
||||
import { ulid } from 'ulidx';
|
||||
import { users } from '~/server/db/schema';
|
||||
import { Roles } from '~/server/web/roles';
|
||||
import log from '~/utils/log';
|
||||
import { createOidcStateCookie } from '~/utils/oidc-state';
|
||||
import type { Route } from './+types/oidc-callback';
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import * as oidc from "openid-client";
|
||||
import { data, redirect } from "react-router";
|
||||
|
||||
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
||||
import log from "~/utils/log";
|
||||
import { createOidcStateCookie } from "~/utils/oidc-state";
|
||||
|
||||
import type { Route } from "./+types/oidc-callback";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
if (!context.oidcConnector?.isValid) {
|
||||
throw data('OIDC is not enabled or misconfigured', { status: 501 });
|
||||
}
|
||||
const oidcConnector = await context.oidc?.connector.get();
|
||||
if (!oidcConnector?.isValid) {
|
||||
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
||||
}
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.toString().length === 0) {
|
||||
return redirect('/login?s=error_no_query');
|
||||
}
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.toString().length === 0) {
|
||||
return redirect("/login?s=error_no_query");
|
||||
}
|
||||
|
||||
const cookie = createOidcStateCookie(context.config);
|
||||
const oidcCookieState = await cookie.parse(request.headers.get('Cookie'));
|
||||
const cookie = createOidcStateCookie(context.config);
|
||||
const oidcCookieState = await cookie.parse(request.headers.get("Cookie"));
|
||||
|
||||
if (oidcCookieState == null) {
|
||||
log.warn('auth', 'Called OIDC callback without session cookie');
|
||||
return redirect('/login?s=error_no_session');
|
||||
}
|
||||
if (oidcCookieState == null) {
|
||||
log.warn("auth", "Called OIDC callback without session cookie");
|
||||
return redirect("/login?s=error_no_session");
|
||||
}
|
||||
|
||||
const { state, nonce, redirect_uri, verifier } = oidcCookieState;
|
||||
if (!state || !nonce || !redirect_uri || !verifier) {
|
||||
log.warn('auth', 'OIDC session cookie is missing required fields');
|
||||
return redirect('/login?s=error_invalid_session');
|
||||
}
|
||||
const { state, nonce, redirect_uri, verifier } = oidcCookieState;
|
||||
if (!state || !nonce || !redirect_uri || !verifier) {
|
||||
log.warn("auth", "OIDC session cookie is missing required fields");
|
||||
return redirect("/login?s=error_invalid_session");
|
||||
}
|
||||
|
||||
try {
|
||||
const callbackUrl = new URL(redirect_uri);
|
||||
const currentUrl = new URL(request.url);
|
||||
callbackUrl.search = currentUrl.search;
|
||||
try {
|
||||
const callbackUrl = new URL(redirect_uri);
|
||||
const currentUrl = new URL(request.url);
|
||||
callbackUrl.search = currentUrl.search;
|
||||
|
||||
const tokens = await oidc.authorizationCodeGrant(
|
||||
context.oidcConnector.client,
|
||||
callbackUrl,
|
||||
{
|
||||
expectedState: state,
|
||||
expectedNonce: nonce,
|
||||
...(context.oidcConnector.usePKCE
|
||||
? { pkceCodeVerifier: verifier }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
const tokens = await oidc.authorizationCodeGrant(oidcConnector.client, callbackUrl, {
|
||||
expectedState: state,
|
||||
expectedNonce: nonce,
|
||||
...(oidcConnector.usePKCE ? { pkceCodeVerifier: verifier } : {}),
|
||||
});
|
||||
|
||||
const claims = tokens.claims();
|
||||
if (claims?.sub == null) {
|
||||
log.warn('auth', 'No subject found in OIDC claims');
|
||||
return redirect('/login?s=error_no_sub');
|
||||
}
|
||||
const claims = tokens.claims();
|
||||
if (claims?.sub == null) {
|
||||
log.warn("auth", "No subject found in OIDC claims");
|
||||
return redirect("/login?s=error_no_sub");
|
||||
}
|
||||
|
||||
const userInfo = await oidc.fetchUserInfo(
|
||||
context.oidcConnector.client,
|
||||
tokens.access_token,
|
||||
claims.sub,
|
||||
);
|
||||
const userInfo = await oidc.fetchUserInfo(
|
||||
oidcConnector.client,
|
||||
tokens.access_token,
|
||||
claims.sub,
|
||||
);
|
||||
|
||||
// We have defaults that closely follow what Headscale uses, maybe we
|
||||
// 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 =
|
||||
userInfo.name ??
|
||||
(userInfo.given_name && userInfo.family_name
|
||||
? `${userInfo.given_name} ${userInfo.family_name}`
|
||||
: (userInfo.preferred_username ?? 'SSO User'));
|
||||
// We have defaults that closely follow what Headscale uses, maybe we
|
||||
// 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 =
|
||||
userInfo.name ??
|
||||
(userInfo.given_name && userInfo.family_name
|
||||
? `${userInfo.given_name} ${userInfo.family_name}`
|
||||
: (userInfo.preferred_username ?? "SSO User"));
|
||||
|
||||
const picture =
|
||||
context.config.oidc?.profile_picture_source === 'gravatar'
|
||||
? (() => {
|
||||
if (!userInfo.email) {
|
||||
return undefined;
|
||||
}
|
||||
const picture =
|
||||
context.config.oidc?.profile_picture_source === "gravatar"
|
||||
? (() => {
|
||||
if (!userInfo.email) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const emailHash = userInfo.email.trim().toLowerCase();
|
||||
const hash = createHash('sha256').update(emailHash).digest('hex');
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
|
||||
})()
|
||||
: userInfo.picture;
|
||||
const emailHash = userInfo.email.trim().toLowerCase();
|
||||
const hash = createHash("sha256").update(emailHash).digest("hex");
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
|
||||
})()
|
||||
: userInfo.picture;
|
||||
|
||||
const [{ count: userCount }] = await context.db
|
||||
.select({ count: count() })
|
||||
.from(users)
|
||||
.where(eq(users.caps, Roles.owner));
|
||||
const userId = await context.auth.findOrCreateUser(claims.sub);
|
||||
|
||||
await context.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: ulid(),
|
||||
sub: claims.sub,
|
||||
caps: userCount === 0 ? Roles.owner : Roles.member,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
try {
|
||||
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
|
||||
const hsUsers = await hsApi.getUsers();
|
||||
const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email);
|
||||
if (hsUser) {
|
||||
await context.auth.linkHeadscaleUser(userId, hsUser.id);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("auth", "Failed to link Headscale user: %s", String(error));
|
||||
}
|
||||
|
||||
return redirect('/', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.createSession({
|
||||
api_key: context.oidcConnector.apiKey,
|
||||
user: {
|
||||
subject: claims.sub,
|
||||
username,
|
||||
name,
|
||||
email: userInfo.email,
|
||||
picture,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof oidc.ResponseBodyError) {
|
||||
log.error(
|
||||
'auth',
|
||||
'Got an OIDC response error body: %s',
|
||||
JSON.stringify(error.cause),
|
||||
);
|
||||
}
|
||||
return redirect("/", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.createOidcSession(userId, {
|
||||
name,
|
||||
email: userInfo.email,
|
||||
username,
|
||||
picture,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof oidc.ResponseBodyError) {
|
||||
log.error("auth", "Got an OIDC response error body: %s", JSON.stringify(error.cause));
|
||||
|
||||
if (error instanceof oidc.AuthorizationResponseError) {
|
||||
log.error(
|
||||
'auth',
|
||||
'Got an OIDC authorization response error: %s',
|
||||
error.error,
|
||||
);
|
||||
}
|
||||
// Check for PKCE-related errors
|
||||
if (
|
||||
error.error.toLowerCase().includes("code_verifier") ||
|
||||
error.error.toLowerCase().includes("code verifier") ||
|
||||
error.error.toLowerCase().includes("pkce")
|
||||
) {
|
||||
log.error(
|
||||
"auth",
|
||||
"PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s",
|
||||
oidcConnector.usePKCE,
|
||||
);
|
||||
|
||||
if (error instanceof oidc.WWWAuthenticateChallengeError) {
|
||||
log.error('auth', 'Got an OIDC WWW-Authenticate challenge error');
|
||||
}
|
||||
|
||||
return redirect('/login?s=error_auth_failed');
|
||||
}
|
||||
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,84 +1,81 @@
|
||||
import * as oidc from 'openid-client';
|
||||
import { data, redirect } from 'react-router';
|
||||
import { HeadplaneConfig } from '~/server/config/config-schema';
|
||||
import { createOidcStateCookie } from '~/utils/oidc-state';
|
||||
import type { Route } from './+types/oidc-start';
|
||||
import * as oidc from "openid-client";
|
||||
import { data, redirect } from "react-router";
|
||||
|
||||
import { HeadplaneConfig } from "~/server/config/config-schema";
|
||||
import { createOidcStateCookie } from "~/utils/oidc-state";
|
||||
|
||||
import type { Route } from "./+types/oidc-start";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
await context.sessions.auth(request);
|
||||
return redirect('/');
|
||||
} catch {}
|
||||
try {
|
||||
await context.auth.require(request);
|
||||
return redirect("/");
|
||||
} catch {}
|
||||
|
||||
if (!context.oidcConnector?.isValid) {
|
||||
throw data('OIDC is not enabled or misconfigured', { status: 501 });
|
||||
}
|
||||
const oidcConnector = await context.oidc?.connector.get();
|
||||
if (!oidcConnector?.isValid) {
|
||||
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
||||
}
|
||||
|
||||
const cookie = createOidcStateCookie(context.config);
|
||||
const redirect_uri = getRedirectUri(context.config, request);
|
||||
const cookie = createOidcStateCookie(context.config);
|
||||
const redirect_uri = getRedirectUri(context.config, request);
|
||||
|
||||
const nonce = oidc.randomNonce();
|
||||
const verifier = oidc.randomPKCECodeVerifier();
|
||||
const state = oidc.randomState();
|
||||
const nonce = oidc.randomNonce();
|
||||
const verifier = oidc.randomPKCECodeVerifier();
|
||||
const state = oidc.randomState();
|
||||
|
||||
const url = oidc.buildAuthorizationUrl(context.oidcConnector.client, {
|
||||
...(context.oidcConnector.extraParams ?? {}),
|
||||
scope: context.oidcConnector.scope,
|
||||
redirect_uri,
|
||||
state,
|
||||
nonce,
|
||||
...(context.oidcConnector.usePKCE
|
||||
? {
|
||||
code_challenge_method: 'S256',
|
||||
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
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.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;
|
||||
}
|
||||
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');
|
||||
}
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
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;
|
||||
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 {
|
||||
restrictToParentElement,
|
||||
restrictToVerticalAxis,
|
||||
} from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
SortableContext,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { GripVertical, Lock } from 'lucide-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 Input from '~/components/Input';
|
||||
import TableList from '~/components/TableList';
|
||||
@@ -37,18 +37,15 @@ export default function ManageDomains({
|
||||
}, [searchDomains]);
|
||||
|
||||
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>
|
||||
<p className="mb-4">
|
||||
Set custom DNS search domains for your Tailnet. When using Magic DNS,
|
||||
your tailnet domain is used as the first search domain.
|
||||
</p>
|
||||
<DndContext
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={(event) => {
|
||||
setActiveId(event.active.id);
|
||||
}}
|
||||
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
|
||||
onDragEnd={(event) => {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
@@ -70,6 +67,9 @@ export default function ManageDomains({
|
||||
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
|
||||
}
|
||||
}}
|
||||
onDragStart={(event) => {
|
||||
setActiveId(event.active.id);
|
||||
}}
|
||||
>
|
||||
<TableList>
|
||||
{magic ? (
|
||||
@@ -91,19 +91,19 @@ export default function ManageDomains({
|
||||
>
|
||||
{localDomains.map((sd, index) => (
|
||||
<Domain
|
||||
key={sd}
|
||||
domain={sd}
|
||||
id={index + 1}
|
||||
isDisabled={isDisabled}
|
||||
key={sd}
|
||||
/>
|
||||
))}
|
||||
<DragOverlay adjustScale>
|
||||
{activeId ? (
|
||||
<Domain
|
||||
isDragging
|
||||
domain={localDomains[(activeId as number) - 1]}
|
||||
id={(activeId as number) - 1}
|
||||
isDisabled={isDisabled}
|
||||
isDragging
|
||||
/>
|
||||
) : undefined}
|
||||
</DragOverlay>
|
||||
@@ -111,28 +111,28 @@ export default function ManageDomains({
|
||||
{isDisabled ? undefined : (
|
||||
<TableList.Item key="add-sd">
|
||||
<Form
|
||||
method="POST"
|
||||
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
|
||||
type="text"
|
||||
className={cn(
|
||||
'border-none font-mono p-0 text-sm',
|
||||
'rounded-none focus:ring-0 w-full ml-1',
|
||||
)}
|
||||
placeholder="Search Domain"
|
||||
label="Search Domain"
|
||||
name="domain"
|
||||
labelHidden
|
||||
isRequired
|
||||
label="Search Domain"
|
||||
labelHidden
|
||||
name="domain"
|
||||
placeholder="Search Domain"
|
||||
type="text"
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
)}
|
||||
type="submit"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
@@ -164,11 +164,11 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
|
||||
|
||||
return (
|
||||
<TableList.Item
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
isSortableDragging ? 'opacity-50' : '',
|
||||
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
|
||||
)}
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
@@ -186,15 +186,15 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
|
||||
</p>
|
||||
{isDragging ? undefined : (
|
||||
<Form method="POST">
|
||||
<input type="hidden" name="action_id" value="remove_domain" />
|
||||
<input type="hidden" name="domain" value={domain} />
|
||||
<input name="action_id" type="hidden" value="remove_domain" />
|
||||
<input name="domain" type="hidden" value={domain} />
|
||||
<Button
|
||||
type="submit"
|
||||
isDisabled={isDisabled}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
@@ -20,14 +20,14 @@ export default function ManageNS({
|
||||
overrideLocalDns,
|
||||
}: Props) {
|
||||
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>
|
||||
<p>
|
||||
Set the nameservers used by devices on the Tailnet to resolve DNS
|
||||
queries.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1054/dns"
|
||||
name="Tailscale DNS Documentation"
|
||||
to="https://tailscale.com/kb/1054/dns"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
@@ -35,12 +35,12 @@ export default function ManageNS({
|
||||
<div className="mt-4">
|
||||
{Object.keys(nameservers).map((key) => (
|
||||
<NameserverList
|
||||
key={key}
|
||||
isGlobal={key === 'global'}
|
||||
isDisabled={isDisabled}
|
||||
isGlobal={key === 'global'}
|
||||
key={key}
|
||||
name={key}
|
||||
nameservers={nameservers}
|
||||
overrideLocalDns={overrideLocalDns}
|
||||
name={key}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -66,16 +66,17 @@ function NameserverList({
|
||||
name,
|
||||
}: ListProps) {
|
||||
const list = isGlobal ? nameservers.global : nameservers[name];
|
||||
const submit = useSubmit();
|
||||
|
||||
if (list.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const submit = useSubmit();
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
{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">
|
||||
Global Nameservers
|
||||
</h2>
|
||||
@@ -87,8 +88,8 @@ function NameserverList({
|
||||
names outside the tailnet. When disabled (default), devices
|
||||
will prefer their local DNS configuration.
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1054/dns#global-nameservers"
|
||||
name="Tailscale Global Nameservers Documentation"
|
||||
to="https://tailscale.com/kb/1054/dns#global-nameservers"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
@@ -96,11 +97,10 @@ function NameserverList({
|
||||
</Tooltip>
|
||||
<p>Override DNS servers</p>
|
||||
<Switch
|
||||
label="Override local DNS settings"
|
||||
className="h-[15px] w-[23px] p-[2px]"
|
||||
switchClassName="h-[9px] w-[9px]"
|
||||
name="override_dns"
|
||||
className="h-[15px] w-[23px] p-0.5"
|
||||
defaultSelected={overrideLocalDns}
|
||||
label="Override local DNS settings"
|
||||
name="override_dns"
|
||||
onChange={(v) => {
|
||||
submit(
|
||||
{
|
||||
@@ -112,6 +112,7 @@ function NameserverList({
|
||||
},
|
||||
);
|
||||
}}
|
||||
switchClassName="h-[9px] w-[9px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -125,20 +126,20 @@ function NameserverList({
|
||||
<TableList.Item key={ns}>
|
||||
<p className="font-mono text-sm">{ns}</p>
|
||||
<Form method="POST">
|
||||
<input type="hidden" name="action_id" value="remove_ns" />
|
||||
<input type="hidden" name="ns" value={ns} />
|
||||
<input name="action_id" type="hidden" value="remove_ns" />
|
||||
<input name="ns" type="hidden" value={ns} />
|
||||
<input
|
||||
type="hidden"
|
||||
name="split_name"
|
||||
type="hidden"
|
||||
value={isGlobal ? 'global' : name}
|
||||
/>
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
@@ -13,14 +13,14 @@ interface Props {
|
||||
|
||||
export default function ManageRecords({ records, isDisabled }: Props) {
|
||||
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>
|
||||
<p>
|
||||
Headscale supports adding custom DNS records to your Tailnet. As of now,
|
||||
only <Code>A</Code> and <Code>AAAA</Code> records are supported.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/dns"
|
||||
name="Headscale DNS Records documentation"
|
||||
to="https://headscale.net/stable/ref/dns"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
@@ -32,7 +32,7 @@ export default function ManageRecords({ records, isDisabled }: Props) {
|
||||
<p className="opacity-50 mx-auto">No DNS records found</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
records.map((record, index) => (
|
||||
records.map((record) => (
|
||||
<TableList.Item key={`${record.name}-${record.value}`}>
|
||||
<div className="flex gap-2 items-center w-full">
|
||||
<p
|
||||
@@ -43,22 +43,24 @@ export default function ManageRecords({ records, isDisabled }: Props) {
|
||||
>
|
||||
{record.type}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2 w-full">
|
||||
<p className="font-mono text-sm">{record.name}</p>
|
||||
<p className="font-mono text-sm">{record.value}</p>
|
||||
<div className="flex flex-col sm:flex-row sm:gap-2 flex-1 min-w-0">
|
||||
<p className="font-mono text-sm truncate">{record.name}</p>
|
||||
<p className="font-mono text-sm truncate opacity-70 sm:opacity-100">
|
||||
{record.value}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Form method="POST">
|
||||
<input type="hidden" name="action_id" value="remove_record" />
|
||||
<input type="hidden" name="record_name" value={record.name} />
|
||||
<input type="hidden" name="record_type" value={record.type} />
|
||||
<input name="action_id" type="hidden" value="remove_record" />
|
||||
<input name="record_name" type="hidden" value={record.name} />
|
||||
<input name="record_type" type="hidden" value={record.type} />
|
||||
<Button
|
||||
type="submit"
|
||||
isDisabled={isDisabled}
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
@@ -9,21 +9,21 @@ interface Props {
|
||||
|
||||
export default function RenameTailnet({ name, isDisabled }: Props) {
|
||||
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>
|
||||
<p>
|
||||
This is the base domain name of your Tailnet. Devices are accessible at{' '}
|
||||
<Code>[device].{name}</Code> when Magic DNS is enabled.
|
||||
</p>
|
||||
<Input
|
||||
isReadOnly
|
||||
labelHidden
|
||||
className="w-3/5 font-medium text-sm"
|
||||
isReadOnly
|
||||
label="Tailnet name"
|
||||
value={name}
|
||||
labelHidden
|
||||
onFocus={(event) => {
|
||||
event.target.select();
|
||||
}}
|
||||
value={name}
|
||||
/>
|
||||
<Dialog>
|
||||
<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
|
||||
behavior and may break existing devices in your tailnet.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="rename_tailnet" />
|
||||
<input name="action_id" type="hidden" value="rename_tailnet" />
|
||||
<Input
|
||||
defaultValue={name}
|
||||
isRequired
|
||||
label="Tailnet name"
|
||||
placeholder="ts.net"
|
||||
defaultValue={name}
|
||||
name="new_name"
|
||||
placeholder="ts.net"
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
|
||||
+194
-194
@@ -1,231 +1,231 @@
|
||||
import { data } from 'react-router';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
import { data } from "react-router";
|
||||
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_network,
|
||||
);
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.write_network);
|
||||
|
||||
if (!check) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
if (!check) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
if (!context.hs.writable()) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
if (!context.hs.writable()) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
// We only need it for health checks which don't require auth
|
||||
const api = context.hsApi.getRuntimeClient('fake-api-key');
|
||||
// We only need it for health checks which don't require auth
|
||||
const api = context.hsApi.getRuntimeClient("fake-api-key");
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
const formData = await request.formData();
|
||||
const action = formData.get("action_id")?.toString();
|
||||
if (!action) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'rename_tailnet': {
|
||||
const newName = formData.get('new_name')?.toString();
|
||||
if (!newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
switch (action) {
|
||||
case "rename_tailnet": {
|
||||
const newName = formData.get("new_name")?.toString();
|
||||
if (!newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.base_domain',
|
||||
value: newName,
|
||||
},
|
||||
]);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "dns.base_domain",
|
||||
value: newName,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'Tailnet renamed successfully' };
|
||||
}
|
||||
case 'toggle_magic': {
|
||||
const newState = formData.get('new_state')?.toString();
|
||||
if (!newState) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "Tailnet renamed successfully" };
|
||||
}
|
||||
case "toggle_magic": {
|
||||
const newState = formData.get("new_state")?.toString();
|
||||
if (!newState) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.magic_dns',
|
||||
value: newState === 'enabled',
|
||||
},
|
||||
]);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "dns.magic_dns",
|
||||
value: newState === "enabled",
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'Magic DNS state updated successfully' };
|
||||
}
|
||||
case 'remove_ns': {
|
||||
const config = context.hs.c!;
|
||||
const ns = formData.get('ns')?.toString();
|
||||
const splitName = formData.get('split_name')?.toString();
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "Magic DNS state updated successfully" };
|
||||
}
|
||||
case "remove_ns": {
|
||||
const config = context.hs.c!;
|
||||
const ns = formData.get("ns")?.toString();
|
||||
const splitName = formData.get("split_name")?.toString();
|
||||
|
||||
if (!ns || !splitName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
if (!ns || !splitName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
if (splitName === 'global') {
|
||||
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
|
||||
if (splitName === "global") {
|
||||
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.nameservers.global',
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName].filter((i) => i !== ns);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "dns.nameservers.global",
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName].filter((i) => i !== ns);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers.length > 0 ? servers : null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers.length > 0 ? servers : null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'Nameserver removed successfully' };
|
||||
}
|
||||
case 'add_ns': {
|
||||
const config = context.hs.c!;
|
||||
const ns = formData.get('ns')?.toString();
|
||||
const splitName = formData.get('split_name')?.toString();
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "Nameserver removed successfully" };
|
||||
}
|
||||
case "add_ns": {
|
||||
const config = context.hs.c!;
|
||||
const ns = formData.get("ns")?.toString();
|
||||
const splitName = formData.get("split_name")?.toString();
|
||||
|
||||
if (!ns || !splitName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
if (!ns || !splitName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
if (splitName === 'global') {
|
||||
const servers = config.dns.nameservers.global;
|
||||
servers.push(ns);
|
||||
if (splitName === "global") {
|
||||
const servers = config.dns.nameservers.global;
|
||||
servers.push(ns);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.nameservers.global',
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName] ?? [];
|
||||
servers.push(ns);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "dns.nameservers.global",
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName] ?? [];
|
||||
servers.push(ns);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
}
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'Nameserver added successfully' };
|
||||
}
|
||||
case 'remove_domain': {
|
||||
const config = context.hs.c!;
|
||||
const domain = formData.get('domain')?.toString();
|
||||
if (!domain) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "Nameserver added successfully" };
|
||||
}
|
||||
case "remove_domain": {
|
||||
const config = context.hs.c!;
|
||||
const domain = formData.get("domain")?.toString();
|
||||
if (!domain) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const domains = config.dns.search_domains.filter((i) => i !== domain);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.search_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
const domains = config.dns.search_domains.filter((i) => i !== domain);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "dns.search_domains",
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'Domain removed successfully' };
|
||||
}
|
||||
case 'add_domain': {
|
||||
const config = context.hs.c!;
|
||||
const domain = formData.get('domain')?.toString();
|
||||
if (!domain) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "Domain removed successfully" };
|
||||
}
|
||||
case "add_domain": {
|
||||
const config = context.hs.c!;
|
||||
const domain = formData.get("domain")?.toString();
|
||||
if (!domain) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const domains = config.dns.search_domains;
|
||||
domains.push(domain);
|
||||
const domains = config.dns.search_domains;
|
||||
domains.push(domain);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.search_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "dns.search_domains",
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'Domain added successfully' };
|
||||
}
|
||||
case 'remove_record': {
|
||||
const recordName = formData.get('record_name')?.toString();
|
||||
const recordType = formData.get('record_type')?.toString();
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "Domain added successfully" };
|
||||
}
|
||||
case "remove_record": {
|
||||
const recordName = formData.get("record_name")?.toString();
|
||||
const recordType = formData.get("record_type")?.toString();
|
||||
|
||||
if (!recordName || !recordType) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
if (!recordName || !recordType) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
// Value is not needed for removal
|
||||
const restart = await context.hs.removeDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: '',
|
||||
});
|
||||
// Value is not needed for removal
|
||||
const restart = await context.hs.removeDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: "",
|
||||
});
|
||||
|
||||
if (!restart) {
|
||||
return;
|
||||
}
|
||||
if (!restart) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'DNS record removed successfully' };
|
||||
}
|
||||
case 'add_record': {
|
||||
const recordName = formData.get('record_name')?.toString();
|
||||
const recordType = formData.get('record_type')?.toString();
|
||||
const recordValue = formData.get('record_value')?.toString();
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "DNS record removed successfully" };
|
||||
}
|
||||
case "add_record": {
|
||||
const recordName = formData.get("record_name")?.toString();
|
||||
const recordType = formData.get("record_type")?.toString();
|
||||
const recordValue = formData.get("record_value")?.toString();
|
||||
|
||||
if (!recordName || !recordType || !recordValue) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
if (!recordName || !recordType || !recordValue) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const restart = await context.hs.addDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: recordValue,
|
||||
});
|
||||
const restart = await context.hs.addDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: recordValue,
|
||||
});
|
||||
|
||||
if (!restart) {
|
||||
return;
|
||||
}
|
||||
if (!restart) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'DNS record added successfully' };
|
||||
}
|
||||
case 'override_dns': {
|
||||
const override = formData.get('override_dns')?.toString();
|
||||
if (!override) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "DNS record added successfully" };
|
||||
}
|
||||
case "override_dns": {
|
||||
const override = formData.get("override_dns")?.toString();
|
||||
if (!override) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const overrideValue = override === 'true';
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.override_local_dns',
|
||||
value: overrideValue,
|
||||
},
|
||||
]);
|
||||
const overrideValue = override === "true";
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "dns.override_local_dns",
|
||||
value: overrideValue,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'DNS override updated successfully' };
|
||||
}
|
||||
default:
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: "DNS override updated successfully" };
|
||||
}
|
||||
default:
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
+86
-98
@@ -1,115 +1,103 @@
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import Notice from '~/components/Notice';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import ManageDomains from './components/manage-domains';
|
||||
import ManageNS from './components/manage-ns';
|
||||
import ManageRecords from './components/manage-records';
|
||||
import RenameTailnet from './components/rename-tailnet';
|
||||
import ToggleMagic from './components/toggle-magic';
|
||||
import { dnsAction } from './dns-actions';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from "react-router";
|
||||
import { useLoaderData } from "react-router";
|
||||
|
||||
import Code from "~/components/Code";
|
||||
import Notice from "~/components/Notice";
|
||||
import type { LoadContext } from "~/server";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import ManageDomains from "./components/manage-domains";
|
||||
import ManageNS from "./components/manage-ns";
|
||||
import ManageRecords from "./components/manage-records";
|
||||
import RenameTailnet from "./components/rename-tailnet";
|
||||
import ToggleMagic from "./components/toggle-magic";
|
||||
import { dnsAction } from "./dns-actions";
|
||||
|
||||
// We do not want to expose every config value
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
if (!context.hs.readable()) {
|
||||
throw new Error('No configuration is available');
|
||||
}
|
||||
export async function loader({ request, context }: LoaderFunctionArgs<LoadContext>) {
|
||||
if (!context.hs.readable()) {
|
||||
throw new Error("No configuration is available");
|
||||
}
|
||||
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.read_network,
|
||||
);
|
||||
if (!check) {
|
||||
// Not authorized to view this page
|
||||
throw new Error(
|
||||
'You do not have permission to view this page. Please contact your administrator.',
|
||||
);
|
||||
}
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.read_network);
|
||||
if (!check) {
|
||||
// Not authorized to view this page
|
||||
throw new Error(
|
||||
"You do not have permission to view this page. Please contact your administrator.",
|
||||
);
|
||||
}
|
||||
|
||||
const writablePermission = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_network,
|
||||
);
|
||||
const writablePermission = context.auth.can(principal, Capabilities.write_network);
|
||||
|
||||
const config = context.hs.c!;
|
||||
const dns = {
|
||||
prefixes: config.prefixes,
|
||||
magicDns: config.dns.magic_dns,
|
||||
baseDomain: config.dns.base_domain,
|
||||
nameservers: config.dns.nameservers.global,
|
||||
splitDns: config.dns.nameservers.split,
|
||||
searchDomains: config.dns.search_domains,
|
||||
overrideDns: config.dns.override_local_dns,
|
||||
extraRecords: context.hs.d,
|
||||
};
|
||||
const config = context.hs.c!;
|
||||
const dns = {
|
||||
prefixes: config.prefixes,
|
||||
magicDns: config.dns.magic_dns,
|
||||
baseDomain: config.dns.base_domain,
|
||||
nameservers: config.dns.nameservers.global,
|
||||
splitDns: config.dns.nameservers.split,
|
||||
searchDomains: config.dns.search_domains,
|
||||
overrideDns: config.dns.override_local_dns,
|
||||
extraRecords: context.hs.d,
|
||||
};
|
||||
|
||||
return {
|
||||
...dns,
|
||||
access: writablePermission,
|
||||
writable: context.hs.writable(),
|
||||
};
|
||||
return {
|
||||
...dns,
|
||||
access: writablePermission,
|
||||
writable: context.hs.writable(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(data: ActionFunctionArgs) {
|
||||
return dnsAction(data);
|
||||
return dnsAction(data);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
const allNs: Record<string, string[]> = {};
|
||||
for (const key of Object.keys(data.splitDns)) {
|
||||
allNs[key] = data.splitDns[key];
|
||||
}
|
||||
const allNs: Record<string, string[]> = {};
|
||||
for (const key of Object.keys(data.splitDns)) {
|
||||
allNs[key] = data.splitDns[key];
|
||||
}
|
||||
|
||||
allNs.global = data.nameservers;
|
||||
const isDisabled = data.access === false || data.writable === false;
|
||||
allNs.global = data.nameservers;
|
||||
const isDisabled = data.access === false || data.writable === false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-16 max-w-(--breakpoint-lg)">
|
||||
{data.writable ? undefined : (
|
||||
<Notice>
|
||||
The Headscale configuration is read-only. You cannot make changes to
|
||||
the configuration
|
||||
</Notice>
|
||||
)}
|
||||
{data.access ? undefined : (
|
||||
<Notice>
|
||||
Your permissions do not allow you to modify the DNS settings for this
|
||||
tailnet.
|
||||
</Notice>
|
||||
)}
|
||||
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
|
||||
<ManageNS
|
||||
nameservers={allNs}
|
||||
isDisabled={isDisabled}
|
||||
overrideLocalDns={data.overrideDns}
|
||||
/>
|
||||
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
|
||||
<ManageDomains
|
||||
searchDomains={data.searchDomains}
|
||||
isDisabled={isDisabled}
|
||||
magic={data.magicDns ? data.baseDomain : undefined}
|
||||
/>
|
||||
return (
|
||||
<div className="flex max-w-(--breakpoint-lg) flex-col gap-16">
|
||||
{data.writable ? undefined : (
|
||||
<Notice>
|
||||
The Headscale configuration is read-only. You cannot make changes to the configuration
|
||||
</Notice>
|
||||
)}
|
||||
{data.access ? undefined : (
|
||||
<Notice>
|
||||
Your permissions do not allow you to modify the DNS settings for this tailnet.
|
||||
</Notice>
|
||||
)}
|
||||
<RenameTailnet isDisabled={isDisabled} name={data.baseDomain} />
|
||||
<ManageNS isDisabled={isDisabled} nameservers={allNs} overrideLocalDns={data.overrideDns} />
|
||||
<ManageRecords isDisabled={isDisabled} records={data.extraRecords} />
|
||||
<ManageDomains
|
||||
isDisabled={isDisabled}
|
||||
magic={data.magicDns ? data.baseDomain : undefined}
|
||||
searchDomains={data.searchDomains}
|
||||
/>
|
||||
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
|
||||
<p className="mb-4">
|
||||
Automatically register domain names for each device on the tailnet.
|
||||
Devices will be accessible at{' '}
|
||||
<Code>
|
||||
[device].
|
||||
{data.baseDomain}
|
||||
</Code>{' '}
|
||||
when Magic DNS is enabled.
|
||||
</p>
|
||||
<ToggleMagic isEnabled={data.magicDns} isDisabled={isDisabled} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
<div className="flex w-full flex-col sm:w-2/3">
|
||||
<h1 className="mb-4 text-2xl font-medium">Magic DNS</h1>
|
||||
<p className="mb-4">
|
||||
Automatically register domain names for each device on the tailnet. Devices will be
|
||||
accessible at{" "}
|
||||
<Code>
|
||||
[device].
|
||||
{data.baseDomain}
|
||||
</Code>{" "}
|
||||
when Magic DNS is enabled.
|
||||
</p>
|
||||
<ToggleMagic isDisabled={isDisabled} isEnabled={data.magicDns} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,228 +1,214 @@
|
||||
import { ChevronDown, Copy } from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import Chip from '~/components/Chip';
|
||||
import Menu from '~/components/Menu';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import { ExitNodeTag } from '~/components/tags/ExitNode';
|
||||
import { ExpiryTag } from '~/components/tags/Expiry';
|
||||
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
|
||||
import { SubnetTag } from '~/components/tags/Subnet';
|
||||
import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH';
|
||||
import type { User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import * as hinfo from '~/utils/host-info';
|
||||
import { PopulatedNode } from '~/utils/node-info';
|
||||
import toast from '~/utils/toast';
|
||||
import MenuOptions from './menu';
|
||||
import { ChevronDown, Copy } from "lucide-react";
|
||||
import { useMemo } from "react";
|
||||
import { Link } from "react-router";
|
||||
|
||||
import Chip from "~/components/Chip";
|
||||
import Menu from "~/components/Menu";
|
||||
import StatusCircle from "~/components/StatusCircle";
|
||||
import { ExitNodeTag } from "~/components/tags/ExitNode";
|
||||
import { ExpiryTag } from "~/components/tags/Expiry";
|
||||
import { HeadplaneAgentTag } from "~/components/tags/HeadplaneAgent";
|
||||
import { SubnetTag } from "~/components/tags/Subnet";
|
||||
import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
|
||||
import type { User } from "~/types";
|
||||
import cn from "~/utils/cn";
|
||||
import * as hinfo from "~/utils/host-info";
|
||||
import { PopulatedNode } from "~/utils/node-info";
|
||||
import { formatTimeDelta } from "~/utils/time";
|
||||
import toast from "~/utils/toast";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
import MenuOptions from "./menu";
|
||||
|
||||
interface Props {
|
||||
node: PopulatedNode;
|
||||
users: User[];
|
||||
isAgent?: boolean;
|
||||
magic?: string;
|
||||
isDisabled?: boolean;
|
||||
existingTags?: string[];
|
||||
node: PopulatedNode;
|
||||
users: User[];
|
||||
isAgent?: boolean;
|
||||
magic?: string;
|
||||
isDisabled?: boolean;
|
||||
existingTags?: string[];
|
||||
supportsNodeOwnerChange: boolean;
|
||||
}
|
||||
|
||||
export default function MachineRow({
|
||||
node,
|
||||
users,
|
||||
isAgent,
|
||||
magic,
|
||||
isDisabled,
|
||||
existingTags,
|
||||
node,
|
||||
users,
|
||||
isAgent,
|
||||
magic,
|
||||
isDisabled,
|
||||
existingTags,
|
||||
supportsNodeOwnerChange,
|
||||
}: Props) {
|
||||
const uiTags = useMemo(() => {
|
||||
const tags = uiTagsForNode(node, isAgent);
|
||||
return tags;
|
||||
}, [node, isAgent]);
|
||||
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
|
||||
|
||||
const ipOptions = useMemo(() => {
|
||||
if (magic) {
|
||||
return [...node.ipAddresses, `${node.givenName}.${magic}`];
|
||||
}
|
||||
const ipOptions = useMemo(() => {
|
||||
if (magic) {
|
||||
return [...node.ipAddresses, `${node.givenName}.${magic}`];
|
||||
}
|
||||
|
||||
return node.ipAddresses;
|
||||
}, [magic, node.ipAddresses]);
|
||||
return node.ipAddresses;
|
||||
}, [magic, node.ipAddresses]);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
||||
key={node.id}
|
||||
>
|
||||
<td className="pl-0.5 py-2 focus-within:ring-3">
|
||||
<Link
|
||||
className={cn('group/link h-full focus:outline-hidden')}
|
||||
to={`/machines/${node.id}`}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'font-semibold leading-snug',
|
||||
'group-hover/link:text-blue-600',
|
||||
'dark:group-hover/link:text-blue-400',
|
||||
)}
|
||||
>
|
||||
{node.givenName}
|
||||
</p>
|
||||
<p className="text-sm opacity-50">
|
||||
{node.user.name ||
|
||||
node.user.displayName ||
|
||||
node.user.email ||
|
||||
node.user.id}
|
||||
</p>
|
||||
<div className="flex gap-1 flex-wrap mt-1.5">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{node.validTags.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
{node.ipAddresses[0]}
|
||||
<Menu placement="bottom end">
|
||||
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel
|
||||
onAction={async (key) => {
|
||||
await navigator.clipboard.writeText(key.toString());
|
||||
toast('Copied IP address to clipboard');
|
||||
}}
|
||||
>
|
||||
<Menu.Section>
|
||||
{ipOptions.map((ip) => (
|
||||
<Menu.Item key={ip} textValue={ip}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between',
|
||||
'text-sm w-full gap-x-6',
|
||||
)}
|
||||
>
|
||||
{ip}
|
||||
<Copy className="w-3 h-3" />
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</div>
|
||||
</td>
|
||||
{/* We pass undefined when agents are not enabled */}
|
||||
{isAgent !== undefined ? (
|
||||
<td className="py-2">
|
||||
{node.hostInfo !== undefined ? (
|
||||
<>
|
||||
<p className="leading-snug">
|
||||
{hinfo.getTSVersion(node.hostInfo)}
|
||||
</p>
|
||||
<p className="text-sm opacity-50 max-w-48 truncate">
|
||||
{hinfo.getOSInfo(node.hostInfo)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm opacity-50">Unknown</p>
|
||||
)}
|
||||
</td>
|
||||
) : undefined}
|
||||
<td className="py-2">
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 text-sm',
|
||||
'text-headplane-600 dark:text-headplane-300',
|
||||
)}
|
||||
>
|
||||
<StatusCircle
|
||||
className="w-4 h-4"
|
||||
isOnline={node.online && !node.expired}
|
||||
/>
|
||||
<p suppressHydrationWarning>
|
||||
{node.online && !node.expired
|
||||
? 'Connected'
|
||||
: new Date(node.lastSeen).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions
|
||||
existingTags={existingTags}
|
||||
isDisabled={isDisabled}
|
||||
magic={magic}
|
||||
node={node}
|
||||
users={users}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
return (
|
||||
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={node.id}>
|
||||
<td className="py-2 pl-0.5 focus-within:ring-3">
|
||||
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
|
||||
<p
|
||||
className={cn(
|
||||
"font-semibold leading-snug",
|
||||
"group-hover/link:text-blue-600",
|
||||
"dark:group-hover/link:text-blue-400",
|
||||
)}
|
||||
>
|
||||
{node.givenName}
|
||||
</p>
|
||||
<p className="text-sm opacity-50">
|
||||
{node.user ? getUserDisplayName(node.user) : "Tag-owned"}
|
||||
</p>
|
||||
<div className="mt-1.5 flex flex-wrap gap-1">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{node.tags?.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
</Link>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
<div className="flex items-center gap-x-1">
|
||||
{node.ipAddresses[0]}
|
||||
<Menu placement="bottom end">
|
||||
<Menu.IconButton className="bg-transparent" label="IP Addresses">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel
|
||||
onAction={async (key) => {
|
||||
await navigator.clipboard.writeText(key.toString());
|
||||
toast("Copied IP address to clipboard");
|
||||
}}
|
||||
>
|
||||
<Menu.Section>
|
||||
{ipOptions.map((ip) => (
|
||||
<Menu.Item key={ip} textValue={ip}>
|
||||
<div
|
||||
className={cn("flex items-center justify-between", "text-sm w-full gap-x-6")}
|
||||
>
|
||||
{ip}
|
||||
<Copy className="h-3 w-3" />
|
||||
</div>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</div>
|
||||
</td>
|
||||
{/* We pass undefined when agents are not enabled */}
|
||||
{isAgent !== undefined ? (
|
||||
<td className="py-2">
|
||||
{node.hostInfo !== undefined ? (
|
||||
<>
|
||||
<p className="leading-snug">{hinfo.getTSVersion(node.hostInfo)}</p>
|
||||
<p className="max-w-48 truncate text-sm opacity-50">
|
||||
{hinfo.getOSInfo(node.hostInfo)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm opacity-50">Unknown</p>
|
||||
)}
|
||||
</td>
|
||||
) : undefined}
|
||||
<td className="py-2">
|
||||
<div className="flex items-start gap-x-1">
|
||||
<StatusCircle className="mt-0.5 h-4 w-4" isOnline={node.online && !node.expired} />
|
||||
<div>
|
||||
<p
|
||||
className={cn("text-sm", "text-headplane-600 dark:text-headplane-300")}
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{node.online && !node.expired
|
||||
? "Connected"
|
||||
: new Date(node.lastSeen).toLocaleString()}
|
||||
</p>
|
||||
{!(node.online && !node.expired) && (
|
||||
<p className="text-xs opacity-50" suppressHydrationWarning>
|
||||
{formatTimeDelta(new Date(node.lastSeen))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions
|
||||
existingTags={existingTags}
|
||||
isDisabled={isDisabled}
|
||||
magic={magic}
|
||||
node={node}
|
||||
users={users}
|
||||
supportsNodeOwnerChange={supportsNodeOwnerChange}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
|
||||
const uiTags: string[] = [];
|
||||
if (node.expired) {
|
||||
uiTags.push('expired');
|
||||
}
|
||||
const uiTags: string[] = [];
|
||||
if (node.expired) {
|
||||
uiTags.push("expired");
|
||||
}
|
||||
|
||||
if (node.expiry === null) {
|
||||
uiTags.push('no-expiry');
|
||||
}
|
||||
if (node.expiry === null) {
|
||||
uiTags.push("no-expiry");
|
||||
}
|
||||
|
||||
if (node.customRouting.exitRoutes.length > 0) {
|
||||
if (node.customRouting.exitApproved) {
|
||||
uiTags.push('exit-approved');
|
||||
} else {
|
||||
uiTags.push('exit-waiting');
|
||||
}
|
||||
}
|
||||
if (node.customRouting.exitRoutes.length > 0) {
|
||||
if (node.customRouting.exitApproved) {
|
||||
uiTags.push("exit-approved");
|
||||
} else {
|
||||
uiTags.push("exit-waiting");
|
||||
}
|
||||
}
|
||||
|
||||
if (node.customRouting.subnetWaitingRoutes.length > 0) {
|
||||
uiTags.push('subnet-waiting');
|
||||
} else if (node.customRouting.subnetApprovedRoutes.length > 0) {
|
||||
uiTags.push('subnet-approved');
|
||||
}
|
||||
if (node.customRouting.subnetWaitingRoutes.length > 0) {
|
||||
uiTags.push("subnet-waiting");
|
||||
} else if (node.customRouting.subnetApprovedRoutes.length > 0) {
|
||||
uiTags.push("subnet-approved");
|
||||
}
|
||||
|
||||
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
|
||||
uiTags.push('tailscale-ssh');
|
||||
}
|
||||
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
|
||||
uiTags.push("tailscale-ssh");
|
||||
}
|
||||
|
||||
if (isAgent === true) {
|
||||
uiTags.push('headplane-agent');
|
||||
}
|
||||
if (isAgent === true) {
|
||||
uiTags.push("headplane-agent");
|
||||
}
|
||||
|
||||
return uiTags;
|
||||
return uiTags;
|
||||
}
|
||||
|
||||
export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
|
||||
return uiTags.map((tag) => {
|
||||
switch (tag) {
|
||||
case 'exit-approved':
|
||||
case 'exit-waiting':
|
||||
return <ExitNodeTag isEnabled={tag === 'exit-approved'} key={tag} />;
|
||||
return uiTags.map((tag) => {
|
||||
switch (tag) {
|
||||
case "exit-approved":
|
||||
case "exit-waiting":
|
||||
return <ExitNodeTag isEnabled={tag === "exit-approved"} key={tag} />;
|
||||
|
||||
case 'subnet-approved':
|
||||
case 'subnet-waiting':
|
||||
return <SubnetTag isEnabled={tag === 'subnet-approved'} key={tag} />;
|
||||
case "subnet-approved":
|
||||
case "subnet-waiting":
|
||||
return <SubnetTag isEnabled={tag === "subnet-approved"} key={tag} />;
|
||||
|
||||
case 'expired':
|
||||
case 'no-expiry':
|
||||
return (
|
||||
<ExpiryTag
|
||||
expiry={node.expiry ?? undefined}
|
||||
key={tag}
|
||||
variant={tag}
|
||||
/>
|
||||
);
|
||||
case "expired":
|
||||
case "no-expiry":
|
||||
return <ExpiryTag expiry={node.expiry ?? undefined} key={tag} variant={tag} />;
|
||||
|
||||
case 'tailscale-ssh':
|
||||
return <TailscaleSSHTag key={tag} />;
|
||||
case "tailscale-ssh":
|
||||
return <TailscaleSSHTag key={tag} />;
|
||||
|
||||
case 'headplane-agent':
|
||||
return <HeadplaneAgentTag key={tag} />;
|
||||
case "headplane-agent":
|
||||
return <HeadplaneAgentTag key={tag} />;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface MenuProps {
|
||||
isFullButton?: boolean;
|
||||
isDisabled?: boolean;
|
||||
existingTags?: string[];
|
||||
supportsNodeOwnerChange: boolean;
|
||||
}
|
||||
|
||||
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
||||
@@ -30,6 +31,7 @@ export default function MachineMenu({
|
||||
isFullButton,
|
||||
isDisabled,
|
||||
existingTags,
|
||||
supportsNodeOwnerChange,
|
||||
}: MenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
const supportsTailscaleSSH =
|
||||
@@ -164,7 +166,7 @@ export default function MachineMenu({
|
||||
<Menu.Item key="rename">Edit machine name</Menu.Item>
|
||||
<Menu.Item key="routes">Edit route settings</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.Item key="expire" textValue="Expire">
|
||||
|
||||
@@ -1,45 +1,43 @@
|
||||
import { Key, useState } from 'react';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Select from '~/components/Select';
|
||||
import type { Machine, User } from '~/types';
|
||||
import { Key, useState } from "react";
|
||||
|
||||
import Dialog from "~/components/Dialog";
|
||||
import Select from "~/components/Select";
|
||||
import type { Machine, User } from "~/types";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
interface MoveProps {
|
||||
machine: Machine;
|
||||
users: User[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
machine: Machine;
|
||||
users: User[];
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
||||
const [userId, setUserId] = useState<Key | null>(null);
|
||||
const [userId, setUserId] = useState<Key | null>(machine.user?.id ?? null);
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
The owner of the machine is the user associated with it.
|
||||
</Dialog.Text>
|
||||
<input name="action_id" type="hidden" value="reassign" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<input name="user_id" type="hidden" value={userId?.toString()} />
|
||||
<Select
|
||||
defaultSelectedKey={machine.user.id}
|
||||
isRequired
|
||||
label="Owner"
|
||||
name="user"
|
||||
onSelectionChange={(key) => {
|
||||
setUserId(key);
|
||||
}}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel isDisabled={userId === machine.user?.id}>
|
||||
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>The owner of the machine is the user associated with it.</Dialog.Text>
|
||||
<input name="action_id" type="hidden" value="reassign" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<input name="user_id" type="hidden" value={userId?.toString()} />
|
||||
<Select
|
||||
defaultSelectedKey={machine.user?.id}
|
||||
isRequired
|
||||
label="Owner"
|
||||
name="user"
|
||||
onSelectionChange={(key) => {
|
||||
setUserId(key);
|
||||
}}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,88 +1,86 @@
|
||||
import { Computer, FileKey2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
import Menu from '~/components/Menu';
|
||||
import Select from '~/components/Select';
|
||||
import type { User } from '~/types';
|
||||
import { Computer, FileKey2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
|
||||
import Code from "~/components/Code";
|
||||
import Dialog from "~/components/Dialog";
|
||||
import Input from "~/components/Input";
|
||||
import Menu from "~/components/Menu";
|
||||
import Select from "~/components/Select";
|
||||
import type { User } from "~/types";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
export interface NewMachineProps {
|
||||
server: string;
|
||||
users: User[];
|
||||
isDisabled?: boolean;
|
||||
disabledKeys?: string[];
|
||||
server: string;
|
||||
users: User[];
|
||||
isDisabled?: boolean;
|
||||
disabledKeys?: string[];
|
||||
}
|
||||
|
||||
export default function NewMachine(data: NewMachineProps) {
|
||||
const [pushDialog, setPushDialog] = useState(false);
|
||||
const [mkey, setMkey] = useState('');
|
||||
const navigate = useNavigate();
|
||||
const [pushDialog, setPushDialog] = useState(false);
|
||||
const [mkey, setMkey] = useState("");
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<>
|
||||
<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 name="action_id" type="hidden" value="register" />
|
||||
<Input
|
||||
isRequired
|
||||
label="Machine Key"
|
||||
name="register_key"
|
||||
onChange={setMkey}
|
||||
placeholder="AbCd..."
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Select
|
||||
isRequired
|
||||
label="Owner"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
<Menu disabledKeys={data.disabledKeys} isDisabled={data.isDisabled}>
|
||||
<Menu.Button variant="heavy">Add Device</Menu.Button>
|
||||
<Menu.Panel
|
||||
onAction={(key) => {
|
||||
if (key === 'register') {
|
||||
setPushDialog(true);
|
||||
return;
|
||||
}
|
||||
const isMkeyInvalid = mkey.length > 0 && mkey.length !== 24;
|
||||
|
||||
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>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
|
||||
<Dialog.Panel isDisabled={mkey.length !== 24}>
|
||||
<Dialog.Title>Register Machine Key</Dialog.Title>
|
||||
<Dialog.Text className="mb-4">
|
||||
The machine key is given when you run{" "}
|
||||
<Code isCopyable>tailscale up --login-server={data.server}</Code> on your device.
|
||||
</Dialog.Text>
|
||||
<input name="action_id" type="hidden" value="register" />
|
||||
<Input
|
||||
errorMessage="Machine key must be exactly 24 characters"
|
||||
isInvalid={isMkeyInvalid}
|
||||
isRequired
|
||||
label="Machine Key"
|
||||
name="register_key"
|
||||
onChange={setMkey}
|
||||
placeholder="AbCd..."
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Select isRequired label="Owner" name="user" placeholder="Select a user">
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
<Menu disabledKeys={data.disabledKeys} isDisabled={data.isDisabled}>
|
||||
<Menu.Button variant="heavy">Add Device</Menu.Button>
|
||||
<Menu.Panel
|
||||
onAction={(key) => {
|
||||
if (key === "register") {
|
||||
setPushDialog(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "pre-auth") {
|
||||
navigate("/settings/auth-keys");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="register" textValue="Register Machine Key">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<Computer className="w-4" />
|
||||
Register Machine Key
|
||||
</div>
|
||||
</Menu.Item>
|
||||
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
|
||||
<div className="flex items-center gap-x-3">
|
||||
<FileKey2 className="w-4" />
|
||||
Generate Pre-auth Key
|
||||
</div>
|
||||
</Menu.Item>
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,106 +1,141 @@
|
||||
import { Plus, TagsIcon, X } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Button from '~/components/Button';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Link from '~/components/Link';
|
||||
import Select from '~/components/Select';
|
||||
import TableList from '~/components/TableList';
|
||||
import type { Machine } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { Plus, TagsIcon, X } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useFetcher } from "react-router";
|
||||
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Dialog from "~/components/Dialog";
|
||||
import Link from "~/components/Link";
|
||||
import Select from "~/components/Select";
|
||||
import TableList from "~/components/TableList";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface TagsProps {
|
||||
machine: Machine;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
existingTags?: string[];
|
||||
machine: Machine;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
existingTags?: string[];
|
||||
}
|
||||
|
||||
export default function Tags({
|
||||
machine,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
existingTags,
|
||||
}: TagsProps) {
|
||||
const [tags, setTags] = useState(machine.forcedTags);
|
||||
const [tag, setTag] = useState('tag:');
|
||||
const tagIsInvalid = useMemo(() => {
|
||||
return tag.length === 0 || !tag.startsWith('tag:') || tags.includes(tag);
|
||||
}, [tag, tags]);
|
||||
export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsProps) {
|
||||
const fetcher = useFetcher();
|
||||
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]);
|
||||
|
||||
const validNodeTags = useMemo(() => {
|
||||
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
|
||||
}, [tags]);
|
||||
const validNodeTags = useMemo(() => {
|
||||
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
|
||||
}, [tags]);
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
|
||||
<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"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>{' '}
|
||||
for more information.
|
||||
</Dialog.Text>
|
||||
<input name="action_id" type="hidden" value="update_tags" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<input name="tags" type="hidden" value={tags.join(',')} />
|
||||
<TableList className="mt-4">
|
||||
{tags.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<TagsIcon />
|
||||
<p className="font-semibold">No tags are set on this machine</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
tags.map((item) => (
|
||||
<TableList.Item className="font-mono" id={item} key={item}>
|
||||
{item}
|
||||
<Button
|
||||
className="rounded-md p-0.5"
|
||||
onPress={() => {
|
||||
setTags(tags.filter((tag) => tag !== item));
|
||||
}}
|
||||
>
|
||||
<X className="p-1" />
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))
|
||||
)}
|
||||
</TableList>
|
||||
const error = fetcher.data && !fetcher.data.success ? fetcher.data.error : null;
|
||||
|
||||
<div className="flex items-center gap-2 mt-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>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
useEffect(() => {
|
||||
if (fetcher.data?.success) {
|
||||
submittingRef.current = false;
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
if (fetcher.state === "idle" && fetcher.data && !fetcher.data.success) {
|
||||
submittingRef.current = false;
|
||||
}
|
||||
}, [fetcher.data, fetcher.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setTags([...machine.tags]);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && submittingRef.current) return;
|
||||
setIsOpen(open);
|
||||
}}
|
||||
>
|
||||
<Dialog.Panel
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
submittingRef.current = true;
|
||||
const form = new FormData();
|
||||
form.set("action_id", "update_tags");
|
||||
form.set("node_id", machine.id);
|
||||
form.set("tags", tags.filter((t) => t !== "").join(","));
|
||||
fetcher.submit(form, { method: "POST" });
|
||||
}}
|
||||
isDisabled={fetcher.state !== "idle"}
|
||||
>
|
||||
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
|
||||
<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">
|
||||
Tailscale documentation
|
||||
</Link>{" "}
|
||||
for more information.
|
||||
</Dialog.Text>
|
||||
{error ? (
|
||||
<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">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<TableList className="mt-4">
|
||||
{tags.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<TagsIcon />
|
||||
<p className="font-semibold">No tags are set on this machine</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
tags.map((item) => (
|
||||
<TableList.Item className="font-mono" id={item} key={item}>
|
||||
{item}
|
||||
<Button
|
||||
className="rounded-md p-0.5"
|
||||
onPress={() => {
|
||||
setTags(tags.filter((tag) => tag !== item));
|
||||
}}
|
||||
>
|
||||
<X className="p-1" />
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
))
|
||||
)}
|
||||
</TableList>
|
||||
|
||||
<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,178 +1,192 @@
|
||||
import { data, redirect } from 'react-router';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/machine';
|
||||
import { data, redirect } from "react-router";
|
||||
|
||||
import { isDataWithApiError } from "~/server/headscale/api/error-client";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/machine";
|
||||
|
||||
export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_machines,
|
||||
);
|
||||
const principal = await context.auth.require(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const formData = await request.formData();
|
||||
const api = context.hsApi.getRuntimeClient(
|
||||
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
|
||||
);
|
||||
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('Missing `action_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const action = formData.get("action_id")?.toString();
|
||||
if (!action) {
|
||||
throw data("Missing `action_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Fast track register since it doesn't require an existing machine
|
||||
if (action === 'register') {
|
||||
if (!check) {
|
||||
throw data('You do not have permission to manage machines', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
// Fast track register since it doesn't require an existing machine
|
||||
if (action === "register") {
|
||||
if (!context.auth.can(principal, Capabilities.write_machines)) {
|
||||
throw data("You do not have permission to manage machines", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const registrationKey = formData.get('register_key')?.toString();
|
||||
if (!registrationKey) {
|
||||
throw data('Missing `register_key` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const registrationKey = formData.get("register_key")?.toString();
|
||||
if (!registrationKey) {
|
||||
throw data("Missing `register_key` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = formData.get('user')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const user = formData.get("user")?.toString();
|
||||
if (!user) {
|
||||
throw data("Missing `user` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const node = await api.registerNode(user, registrationKey);
|
||||
return redirect(`/machines/${node.id}`);
|
||||
}
|
||||
const node = await api.registerNode(user, registrationKey);
|
||||
return redirect(`/machines/${node.id}`);
|
||||
}
|
||||
|
||||
// Check if the user has permission to manage this machine
|
||||
const nodeId = formData.get('node_id')?.toString();
|
||||
if (!nodeId) {
|
||||
throw data('Missing `node_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
// Check if the user has permission to manage this machine
|
||||
const nodeId = formData.get("node_id")?.toString();
|
||||
if (!nodeId) {
|
||||
throw data("Missing `node_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const node = await api.getNode(nodeId);
|
||||
if (!node) {
|
||||
throw data(`Machine with ID ${nodeId} not found`, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
const node = await api.getNode(nodeId);
|
||||
if (!node) {
|
||||
throw data(`Machine with ID ${nodeId} not found`, {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
node.user.providerId?.split('/').pop() !== session.user.subject &&
|
||||
!check
|
||||
) {
|
||||
throw data('You do not have permission to act on this machine', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
if (!context.auth.canManageNode(principal, node)) {
|
||||
throw data("You do not have permission to act on this machine", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'rename': {
|
||||
const newName = formData.get('name')?.toString();
|
||||
if (!newName) {
|
||||
throw data('Missing `name` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
switch (action) {
|
||||
case "rename": {
|
||||
const newName = formData.get("name")?.toString();
|
||||
if (!newName) {
|
||||
throw data("Missing `name` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const name = String(formData.get('name'));
|
||||
await api.renameNode(nodeId, name);
|
||||
return { message: 'Machine renamed' };
|
||||
}
|
||||
const name = String(formData.get("name"));
|
||||
await api.renameNode(nodeId, name);
|
||||
return { message: "Machine renamed" };
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
await api.deleteNode(nodeId);
|
||||
return redirect('/machines');
|
||||
}
|
||||
case "delete": {
|
||||
await api.deleteNode(nodeId);
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
case 'expire': {
|
||||
await api.expireNode(nodeId);
|
||||
return { message: 'Machine expired' };
|
||||
}
|
||||
case "expire": {
|
||||
await api.expireNode(nodeId);
|
||||
return { message: "Machine expired" };
|
||||
}
|
||||
|
||||
case 'update_tags': {
|
||||
const tags = formData.get('tags')?.toString().split(',') ?? [];
|
||||
if (tags.length === 0) {
|
||||
throw data('Missing `tags` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
case "update_tags": {
|
||||
const tags = formData.get("tags")?.toString().split(",") ?? [];
|
||||
if (tags.length === 0) {
|
||||
throw data("Missing `tags` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.setNodeTags(
|
||||
nodeId,
|
||||
tags.map((tag) => tag.trim()).filter((tag) => tag !== ''),
|
||||
);
|
||||
return { message: 'Tags updated' };
|
||||
}
|
||||
try {
|
||||
await api.setNodeTags(
|
||||
nodeId,
|
||||
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
|
||||
);
|
||||
|
||||
case 'update_routes': {
|
||||
const newApproved = node.approvedRoutes;
|
||||
const routes = formData.get('routes')?.toString();
|
||||
if (!routes) {
|
||||
throw data('Missing `routes` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
return { success: true as const, message: "Tags updated" };
|
||||
} catch (error) {
|
||||
if (isDataWithApiError(error) && error.data.statusCode === 400) {
|
||||
return data(
|
||||
{
|
||||
success: false as const,
|
||||
error:
|
||||
"One or more tags are not defined in your ACL policy. Please add them to your policy before assigning them to a machine.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const allRoutes = routes.split(',').map((route) => route.trim());
|
||||
if (allRoutes.length === 0) {
|
||||
throw data('No routes provided to update', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const enabled = formData.get('enabled')?.toString();
|
||||
if (enabled === undefined) {
|
||||
throw data('Missing `enabled` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
case "update_routes": {
|
||||
const newApproved = node.approvedRoutes;
|
||||
const routes = formData.get("routes")?.toString();
|
||||
if (!routes) {
|
||||
throw data("Missing `routes` 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;
|
||||
}
|
||||
const allRoutes = routes.split(",").map((route) => route.trim());
|
||||
if (allRoutes.length === 0) {
|
||||
throw data("No routes provided to update", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
newApproved.push(route);
|
||||
}
|
||||
} else {
|
||||
for (const route of allRoutes) {
|
||||
// If not approved, skip, otherwise remove from approved
|
||||
if (!newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
const enabled = formData.get("enabled")?.toString();
|
||||
if (enabled === undefined) {
|
||||
throw data("Missing `enabled` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const index = newApproved.indexOf(route);
|
||||
if (index > -1) {
|
||||
newApproved.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (enabled === "true") {
|
||||
for (const route of allRoutes) {
|
||||
// If already approved, skip, otherwise add to approved
|
||||
if (newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await api.approveNodeRoutes(nodeId, newApproved);
|
||||
return { message: 'Routes updated' };
|
||||
}
|
||||
newApproved.push(route);
|
||||
}
|
||||
} else {
|
||||
for (const route of allRoutes) {
|
||||
// If not approved, skip, otherwise remove from approved
|
||||
if (!newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
case 'reassign': {
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const index = newApproved.indexOf(route);
|
||||
if (index > -1) {
|
||||
newApproved.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await api.setNodeUser(nodeId, user);
|
||||
return { message: 'Machine reassigned' };
|
||||
}
|
||||
await api.approveNodeRoutes(nodeId, newApproved);
|
||||
return { message: "Routes updated" };
|
||||
}
|
||||
|
||||
default:
|
||||
throw data('Invalid action', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
case "reassign": {
|
||||
const user = formData.get("user_id")?.toString();
|
||||
if (!user) {
|
||||
throw data("Missing `user_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.setNodeUser(nodeId, user);
|
||||
return { message: "Machine reassigned" };
|
||||
}
|
||||
|
||||
default:
|
||||
throw data("Invalid action", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+334
-384
@@ -1,408 +1,358 @@
|
||||
import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { data, Link as RemixLink } 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 cn from '~/utils/cn';
|
||||
import { getOSInfo, getTSVersion } from '~/utils/host-info';
|
||||
import { mapNodes, sortNodeTags } from '~/utils/node-info';
|
||||
import type { Route } from './+types/machine';
|
||||
import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
|
||||
import MenuOptions from './components/menu';
|
||||
import Routes from './dialogs/routes';
|
||||
import { machineAction } from './machine-actions';
|
||||
import { CheckCircle, CircleSlash, Info, UserCircle } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { data, Link as RemixLink } 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 cn from "~/utils/cn";
|
||||
import { getOSInfo, getTSVersion } from "~/utils/host-info";
|
||||
import { mapNodes, sortNodeTags } from "~/utils/node-info";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
import type { Route } from "./+types/machine";
|
||||
import { mapTagsToComponents, uiTagsForNode } from "./components/machine-row";
|
||||
import MenuOptions from "./components/menu";
|
||||
import Routes from "./dialogs/routes";
|
||||
import { machineAction } from "./machine-actions";
|
||||
|
||||
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (!params.id) {
|
||||
throw new Error('No machine ID provided');
|
||||
}
|
||||
const principal = await context.auth.require(request);
|
||||
if (!params.id) {
|
||||
throw new Error("No machine ID provided");
|
||||
}
|
||||
|
||||
if (params.id.endsWith('.ico')) {
|
||||
throw data(null, { status: 204 });
|
||||
}
|
||||
if (params.id.endsWith(".ico")) {
|
||||
throw data(null, { status: 204 });
|
||||
}
|
||||
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||
const node = nodes.find((node) => node.id === params.id);
|
||||
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 = Array.from(
|
||||
new Set([...node.validTags, ...node.forcedTags]),
|
||||
).sort();
|
||||
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),
|
||||
};
|
||||
return {
|
||||
node: enhancedNode,
|
||||
tags,
|
||||
users,
|
||||
magic,
|
||||
agent: context.agents?.agentID(),
|
||||
stats: lookup?.[enhancedNode.nodeKey],
|
||||
existingTags: sortNodeTags(nodes),
|
||||
supportsNodeOwnerChange: supportsNodeOwnerChange,
|
||||
};
|
||||
}
|
||||
|
||||
export const action = machineAction;
|
||||
|
||||
export default function Page({
|
||||
loaderData: { node, tags, users, magic, agent, stats, existingTags },
|
||||
loaderData: { node, tags, users, magic, agent, stats, existingTags, supportsNodeOwnerChange },
|
||||
}: Route.ComponentProps) {
|
||||
const [showRouting, setShowRouting] = useState(false);
|
||||
const [showRouting, setShowRouting] = useState(false);
|
||||
|
||||
const uiTags = useMemo(() => {
|
||||
const tags = uiTagsForNode(node, agent === node.nodeKey);
|
||||
return tags;
|
||||
}, [node, agent]);
|
||||
const uiTags = useMemo(() => {
|
||||
const tags = uiTagsForNode(node, agent === node.nodeKey);
|
||||
return tags;
|
||||
}, [node, agent]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-8 text-md">
|
||||
<RemixLink className="font-medium" to="/machines">
|
||||
All Machines
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span>
|
||||
{node.givenName}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
'flex justify-between items-center pb-2',
|
||||
'border-b border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
<span className="flex items-baseline gap-x-4 text-sm">
|
||||
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
||||
<StatusCircle className="w-4 h-4" isOnline={node.online} />
|
||||
</span>
|
||||
<MenuOptions
|
||||
existingTags={existingTags}
|
||||
isFullButton
|
||||
magic={magic}
|
||||
node={node}
|
||||
users={users}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 mb-4">
|
||||
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
||||
<span className="text-sm text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Managed by
|
||||
<Tooltip>
|
||||
<Info className="p-1" />
|
||||
<Tooltip.Body>
|
||||
By default, a machine’s permissions match its creator’s.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="flex items-center gap-x-2.5 mt-1">
|
||||
<UserCircle />
|
||||
{node.user.name ||
|
||||
node.user.displayName ||
|
||||
node.user.email ||
|
||||
node.user.id}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 pl-4">
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300">
|
||||
Status
|
||||
</p>
|
||||
<div className="flex gap-1 mt-1 mb-8">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{tags.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
|
||||
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p>
|
||||
Subnets let you expose physical network routes onto Tailscale.{' '}
|
||||
<Link
|
||||
name="Tailscale Subnets Documentation"
|
||||
to="https://tailscale.com/kb/1019/subnets"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
<Button onPress={() => setShowRouting(true)}>Review</Button>
|
||||
</div>
|
||||
<Card
|
||||
className={cn(
|
||||
'w-full max-w-full grid sm:grid-cols-2',
|
||||
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
|
||||
)}
|
||||
variant="flat"
|
||||
>
|
||||
<div>
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Approved
|
||||
<Tooltip>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<Tooltip.Body>
|
||||
Traffic to these routes are being routed through this machine.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{node.customRouting.subnetApprovedRoutes.map((route) => (
|
||||
<li key={route}>{route}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
)}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Awaiting Approval
|
||||
<Tooltip>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<Tooltip.Body>
|
||||
This machine is advertising these routes, but they must be
|
||||
approved before traffic will be routed to them.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{node.customRouting.subnetWaitingRoutes.map((route) => (
|
||||
<li key={route}>{route}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
)}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Exit Node
|
||||
<Tooltip>
|
||||
<Info className="w-3.5 h-3.5" />
|
||||
<Tooltip.Body>
|
||||
Whether this machine can act as an exit node for your tailnet.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.exitRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : node.customRouting.exitApproved ? (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-700" />
|
||||
Allowed
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<CircleSlash className="w-3.5 h-3.5 text-red-700" />
|
||||
Awaiting Approval
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded-md mt-1.5',
|
||||
'text-blue-500 dark:text-blue-400',
|
||||
)}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<h2 className="text-xl font-medium">Machine Details</h2>
|
||||
<p className="mb-4">
|
||||
Information about this machine’s network. Used to debug connection
|
||||
issues.
|
||||
</p>
|
||||
<Card
|
||||
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12"
|
||||
variant="flat"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Attribute
|
||||
name="Creator"
|
||||
value={
|
||||
node.user.name ||
|
||||
node.user.displayName ||
|
||||
node.user.email ||
|
||||
node.user.id
|
||||
}
|
||||
/>
|
||||
<Attribute name="Machine name" value={node.givenName} />
|
||||
<Attribute
|
||||
name="OS hostname"
|
||||
tooltip="OS hostname is published by the machine’s operating system and is used as the default name for the machine."
|
||||
value={node.name}
|
||||
/>
|
||||
{stats ? (
|
||||
<>
|
||||
<Attribute name="OS" value={getOSInfo(stats)} />
|
||||
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
|
||||
</>
|
||||
) : undefined}
|
||||
<Attribute
|
||||
name="ID"
|
||||
tooltip="ID for this machine. Used in the Headscale API."
|
||||
value={node.id}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Node key"
|
||||
tooltip="Public key which uniquely identifies this machine."
|
||||
value={node.nodeKey}
|
||||
/>
|
||||
<Attribute
|
||||
name="Created"
|
||||
value={new Date(node.createdAt).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Last Seen"
|
||||
value={
|
||||
node.online
|
||||
? 'Connected'
|
||||
: new Date(node.lastSeen).toLocaleString()
|
||||
}
|
||||
/>
|
||||
<Attribute
|
||||
name="Key expiry"
|
||||
value={
|
||||
node.expiry !== null
|
||||
? new Date(node.expiry).toLocaleString()
|
||||
: 'Never'
|
||||
}
|
||||
/>
|
||||
{magic ? (
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Domain"
|
||||
value={`${node.givenName}.${magic}`}
|
||||
/>
|
||||
) : undefined}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="uppercase text-sm font-semibold opacity-75">
|
||||
Addresses
|
||||
</p>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Tailscale IPv4"
|
||||
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
||||
value={getIpv4Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Tailscale IPv6"
|
||||
tooltip="This machine’s IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
|
||||
value={getIpv6Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
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>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<p className="text-md mb-8">
|
||||
<RemixLink className="font-medium" to="/machines">
|
||||
All Machines
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span>
|
||||
{node.givenName}
|
||||
</p>
|
||||
<div
|
||||
className={cn(
|
||||
"flex justify-between items-center pb-2",
|
||||
"border-b border-headplane-100 dark:border-headplane-800",
|
||||
)}
|
||||
>
|
||||
<span className="flex items-baseline gap-x-4 text-sm">
|
||||
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
||||
<StatusCircle className="h-4 w-4" isOnline={node.online} />
|
||||
</span>
|
||||
<MenuOptions
|
||||
existingTags={existingTags}
|
||||
isFullButton
|
||||
magic={magic}
|
||||
node={node}
|
||||
users={users}
|
||||
supportsNodeOwnerChange={supportsNodeOwnerChange}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex gap-1">
|
||||
<div className="border-headplane-100 dark:border-headplane-800 border-r p-2 pr-4">
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1 text-sm">
|
||||
Managed by
|
||||
<Tooltip>
|
||||
<Info className="p-1" />
|
||||
<Tooltip.Body>By default, a machine’s permissions match its creator’s.</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1 flex items-center gap-x-2.5">
|
||||
<UserCircle />
|
||||
{node.user ? getUserDisplayName(node.user) : "Tag-owned"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-2 pl-4">
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-sm">Status</p>
|
||||
<div className="mt-1 mb-8 flex gap-1">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
{tags.map((tag) => (
|
||||
<Chip key={tag} text={tag} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
|
||||
<h2 className="mt-8 text-xl font-medium">Subnets & Routing</h2>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p>
|
||||
Subnets let you expose physical network routes onto Tailscale.{" "}
|
||||
<Link name="Tailscale Subnets Documentation" to="https://tailscale.com/kb/1019/subnets">
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
<Button onPress={() => setShowRouting(true)}>Review</Button>
|
||||
</div>
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full max-w-full grid sm:grid-cols-2",
|
||||
"md:grid-cols-4 gap-8 mr-2 text-sm mb-8",
|
||||
)}
|
||||
variant="flat"
|
||||
>
|
||||
<div>
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Approved
|
||||
<Tooltip>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
<Tooltip.Body>
|
||||
Traffic to these routes are being routed through this machine.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{node.customRouting.subnetApprovedRoutes.map((route) => (
|
||||
<li key={route}>{route}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Awaiting Approval
|
||||
<Tooltip>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
<Tooltip.Body>
|
||||
This machine is advertising these routes, but they must be approved before traffic
|
||||
will be routed to them.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : (
|
||||
<ul className="leading-normal">
|
||||
{node.customRouting.subnetWaitingRoutes.map((route) => (
|
||||
<li key={route}>{route}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
|
||||
Exit Node
|
||||
<Tooltip>
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
<Tooltip.Body>
|
||||
Whether this machine can act as an exit node for your tailnet.
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
</span>
|
||||
<div className="mt-1">
|
||||
{node.customRouting.exitRoutes.length === 0 ? (
|
||||
<span className="opacity-50">—</span>
|
||||
) : node.customRouting.exitApproved ? (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<CheckCircle className="h-3.5 w-3.5 text-green-700" />
|
||||
Allowed
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-x-1">
|
||||
<CircleSlash className="h-3.5 w-3.5 text-red-700" />
|
||||
Awaiting Approval
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
|
||||
onPress={() => setShowRouting(true)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
<h2 className="text-xl font-medium">Machine Details</h2>
|
||||
<p className="mb-4">
|
||||
Information about this machine’s network. Used to debug connection issues.
|
||||
</p>
|
||||
<Card
|
||||
className="grid w-full max-w-full grid-cols-1 gap-y-2 sm:gap-x-12 lg:grid-cols-2"
|
||||
variant="flat"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Attribute
|
||||
name="Creator"
|
||||
value={node.user ? getUserDisplayName(node.user) : "Tag-owned"}
|
||||
/>
|
||||
<Attribute name="Machine name" value={node.givenName} />
|
||||
<Attribute
|
||||
name="OS hostname"
|
||||
tooltip="OS hostname is published by the machine’s operating system and is used as the default name for the machine."
|
||||
value={node.name}
|
||||
/>
|
||||
{stats ? (
|
||||
<>
|
||||
<Attribute name="OS" value={getOSInfo(stats)} />
|
||||
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
|
||||
</>
|
||||
) : undefined}
|
||||
<Attribute
|
||||
name="ID"
|
||||
tooltip="ID for this machine. Used in the Headscale API."
|
||||
value={node.id}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Node key"
|
||||
tooltip="Public key which uniquely identifies this machine."
|
||||
value={node.nodeKey}
|
||||
/>
|
||||
<Attribute name="Created" value={new Date(node.createdAt).toLocaleString()} />
|
||||
<Attribute
|
||||
name="Last Seen"
|
||||
value={node.online ? "Connected" : new Date(node.lastSeen).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
name="Key expiry"
|
||||
value={node.expiry !== null ? new Date(node.expiry).toLocaleString() : "Never"}
|
||||
/>
|
||||
{magic ? (
|
||||
<Attribute isCopyable name="Domain" value={`${node.givenName}.${magic}`} />
|
||||
) : undefined}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-semibold uppercase opacity-75">Addresses</p>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Tailscale IPv4"
|
||||
tooltip="This machine’s IPv4 address within your tailnet (your private Tailscale network)."
|
||||
value={getIpv4Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Tailscale IPv6"
|
||||
tooltip="This machine’s IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
|
||||
value={getIpv6Address(node.ipAddresses)}
|
||||
/>
|
||||
<Attribute
|
||||
isCopyable
|
||||
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="mt-4 text-sm font-semibold uppercase opacity-75">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[]) {
|
||||
for (const address of addresses) {
|
||||
if (address.startsWith('100.')) {
|
||||
// Return the first CGNAT address
|
||||
return address;
|
||||
}
|
||||
}
|
||||
for (const address of addresses) {
|
||||
if (address.startsWith("100.")) {
|
||||
// Return the first CGNAT address
|
||||
return address;
|
||||
}
|
||||
}
|
||||
|
||||
return '—';
|
||||
return "—";
|
||||
}
|
||||
|
||||
function getIpv6Address(addresses: string[]) {
|
||||
for (const address of addresses) {
|
||||
if (address.startsWith('fd')) {
|
||||
// Return the first IPv6 address
|
||||
return address;
|
||||
}
|
||||
}
|
||||
for (const address of addresses) {
|
||||
if (address.startsWith("fd")) {
|
||||
// Return the first IPv6 address
|
||||
return address;
|
||||
}
|
||||
}
|
||||
|
||||
return '—';
|
||||
return "—";
|
||||
}
|
||||
|
||||
+361
-135
@@ -1,149 +1,375 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import cn from '~/utils/cn';
|
||||
import { mapNodes, sortNodeTags } from '~/utils/node-info';
|
||||
import type { Route } from './+types/overview';
|
||||
import MachineRow from './components/machine-row';
|
||||
import NewMachine from './dialogs/new';
|
||||
import { machineAction } from './machine-actions';
|
||||
import { ChevronDown, ChevronUp, Info, X } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import Code from "~/components/Code";
|
||||
import Input from "~/components/Input";
|
||||
import Link from "~/components/Link";
|
||||
import Tooltip from "~/components/Tooltip";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import cn from "~/utils/cn";
|
||||
import { mapNodes, sortNodeTags } from "~/utils/node-info";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
import MachineRow from "./components/machine-row";
|
||||
import NewMachine from "./dialogs/new";
|
||||
import { machineAction } from "./machine-actions";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const user = session.user;
|
||||
if (!user) {
|
||||
throw new Error('Missing user session. Please log in again.');
|
||||
}
|
||||
const principal = await context.auth.require(request);
|
||||
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.read_machines,
|
||||
);
|
||||
if (!context.auth.can(principal, Capabilities.read_machines)) {
|
||||
throw new Error(
|
||||
"You do not have permission to view this page. Please contact your administrator.",
|
||||
);
|
||||
}
|
||||
|
||||
if (!check) {
|
||||
// Not authorized to view this page
|
||||
throw new Error(
|
||||
'You do not have permission to view this page. Please contact your administrator.',
|
||||
);
|
||||
}
|
||||
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
|
||||
|
||||
const writablePermission = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_machines,
|
||||
);
|
||||
const api = context.hsApi.getRuntimeClient(
|
||||
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
|
||||
);
|
||||
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
|
||||
const populatedNodes = mapNodes(nodes, stats);
|
||||
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
|
||||
|
||||
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
|
||||
const populatedNodes = mapNodes(nodes, stats);
|
||||
|
||||
return {
|
||||
populatedNodes,
|
||||
nodes,
|
||||
users,
|
||||
magic,
|
||||
server: context.config.headscale.url,
|
||||
publicServer: context.config.headscale.public_url,
|
||||
agent: context.agents?.agentID(),
|
||||
writable: writablePermission,
|
||||
preAuth: await context.sessions.check(
|
||||
request,
|
||||
Capabilities.generate_authkeys,
|
||||
),
|
||||
subject: user.subject,
|
||||
};
|
||||
return {
|
||||
populatedNodes,
|
||||
nodes,
|
||||
users,
|
||||
magic,
|
||||
server: context.config.headscale.url,
|
||||
publicServer: context.config.headscale.public_url,
|
||||
agent: context.agents?.agentID(),
|
||||
writable: writablePermission,
|
||||
preAuth: context.auth.can(principal, Capabilities.generate_authkeys),
|
||||
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
|
||||
supportsNodeOwnerChange: supportsNodeOwnerChange,
|
||||
};
|
||||
}
|
||||
|
||||
export const action = machineAction;
|
||||
|
||||
type SortField = "name" | "ip" | "version" | "lastSeen";
|
||||
|
||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-2">Machines</h1>
|
||||
<p>
|
||||
Manage the devices connected to your Tailnet.{' '}
|
||||
<Link
|
||||
name="Tailscale Manage Devices Documentation"
|
||||
to="https://tailscale.com/kb/1372/manage-devices"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<NewMachine
|
||||
disabledKeys={loaderData.preAuth ? [] : ['pre-auth']}
|
||||
isDisabled={!loaderData.writable}
|
||||
server={loaderData.publicServer ?? loaderData.server}
|
||||
users={loaderData.users}
|
||||
/>
|
||||
</div>
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="text-left px-0.5">
|
||||
<th className="uppercase text-xs font-bold pb-2">Name</th>
|
||||
<th className="pb-2 w-1/4">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<p className="uppercase text-xs font-bold">Addresses</p>
|
||||
{loaderData.magic ? (
|
||||
<Tooltip>
|
||||
<Info className="w-4 h-4" />
|
||||
<Tooltip.Body className="font-normal">
|
||||
Since MagicDNS is enabled, you can access devices based on
|
||||
their name and also at{' '}
|
||||
<Code>
|
||||
[name].
|
||||
{loaderData.magic}
|
||||
</Code>
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</div>
|
||||
</th>
|
||||
{/* We only want to show the version column if there are agents */}
|
||||
{loaderData.agent !== undefined ? (
|
||||
<th className="uppercase text-xs font-bold pb-2">Version</th>
|
||||
) : undefined}
|
||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
|
||||
'border-t border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{loaderData.populatedNodes.map((node) => (
|
||||
<MachineRow
|
||||
existingTags={sortNodeTags(loaderData.nodes)}
|
||||
isAgent={
|
||||
loaderData.agent ? loaderData.agent === node.nodeKey : undefined
|
||||
}
|
||||
isDisabled={
|
||||
loaderData.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
: node.user.providerId?.split('/').pop() !==
|
||||
loaderData.subject
|
||||
}
|
||||
key={node.id}
|
||||
magic={loaderData.magic}
|
||||
node={node}
|
||||
users={loaderData.users}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [sortField, setSortField] = useState<SortField>("name");
|
||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
|
||||
|
||||
const filteredAndSortedNodes = useMemo(() => {
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
|
||||
let nodes = loaderData.populatedNodes.filter((node) => {
|
||||
if (!query) return true;
|
||||
if (node.givenName.toLowerCase().includes(query)) return true;
|
||||
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query))) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
nodes = [...nodes].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortField) {
|
||||
case "name":
|
||||
comparison = a.givenName.localeCompare(b.givenName);
|
||||
break;
|
||||
case "ip": {
|
||||
const getIPv4 = (addresses: string[]) =>
|
||||
addresses.find((ip) => !ip.includes(":")) || addresses[0] || "";
|
||||
const ipA = getIPv4(a.ipAddresses);
|
||||
const ipB = getIPv4(b.ipAddresses);
|
||||
|
||||
if (!ipA.includes(":") && !ipB.includes(":")) {
|
||||
const octetsA = ipA.split(".").map(Number);
|
||||
const octetsB = ipB.split(".").map(Number);
|
||||
for (let i = 0; i < 4; i++) {
|
||||
if (octetsA[i] !== octetsB[i]) {
|
||||
comparison = octetsA[i] - octetsB[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
comparison = ipA.localeCompare(ipB);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "version": {
|
||||
const versionA = a.hostInfo?.IPNVersion?.split("-")[0] || "0";
|
||||
const versionB = b.hostInfo?.IPNVersion?.split("-")[0] || "0";
|
||||
const partsA = versionA.split(".").map(Number);
|
||||
const partsB = versionB.split(".").map(Number);
|
||||
const maxLen = Math.max(partsA.length, partsB.length);
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const segA = partsA[i] || 0;
|
||||
const segB = partsB[i] || 0;
|
||||
if (segA !== segB) {
|
||||
comparison = segA - segB;
|
||||
break;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "lastSeen":
|
||||
if (a.online !== b.online) {
|
||||
comparison = a.online ? 1 : -1;
|
||||
break;
|
||||
}
|
||||
comparison = new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime();
|
||||
break;
|
||||
}
|
||||
|
||||
return sortDirection === "asc" ? comparison : -comparison;
|
||||
});
|
||||
|
||||
return nodes;
|
||||
}, [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,95 +1,123 @@
|
||||
import { data } from 'react-router';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
import { data } from "react-router";
|
||||
|
||||
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.generate_authkeys,
|
||||
);
|
||||
const principal = await context.auth.require(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
if (!check) {
|
||||
throw data('You do not have permission to manage pre-auth keys', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
|
||||
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
|
||||
|
||||
const formData = await request.formData();
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('Missing `action_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!canGenerateAny && !canGenerateOwn) {
|
||||
throw data("You do not have permission to manage pre-auth keys", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add_preauthkey': {
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
return data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
async function checkSelfServiceOwnership(userId: string) {
|
||||
if (canGenerateAny || !canGenerateOwn) return;
|
||||
const [targetUser] = await api.getUsers(userId);
|
||||
if (!targetUser) {
|
||||
throw data("User not found.", { status: 404 });
|
||||
}
|
||||
const targetSubject = getOidcSubject(targetUser);
|
||||
if (principal.kind !== "oidc" || targetSubject !== principal.user.subject) {
|
||||
throw data("You do not have permission to manage this user's pre-auth keys", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const expiry = formData.get('expiry')?.toString();
|
||||
if (!expiry) {
|
||||
return data('Missing `expiry` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const formData = await request.formData();
|
||||
const action = formData.get("action_id")?.toString();
|
||||
if (!action) {
|
||||
throw data("Missing `action_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const reusable = formData.get('reusable')?.toString();
|
||||
if (!reusable) {
|
||||
return data('Missing `reusable` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
switch (action) {
|
||||
case "add_preauthkey": {
|
||||
const user = formData.get("user_id")?.toString() || null;
|
||||
const aclTagsRaw = formData.get("acl_tags")?.toString() || "";
|
||||
const aclTags = aclTagsRaw
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0);
|
||||
|
||||
const ephemeral = formData.get('ephemeral')?.toString();
|
||||
if (!ephemeral) {
|
||||
return data('Missing `ephemeral` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!user && aclTags.length === 0) {
|
||||
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
|
||||
const day = Number(expiry.toString().split(' ')[0]);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + day);
|
||||
await api.createPreAuthKey(
|
||||
user,
|
||||
ephemeral === 'on',
|
||||
reusable === 'on',
|
||||
date,
|
||||
[], // TODO
|
||||
);
|
||||
if (user) {
|
||||
await checkSelfServiceOwnership(user);
|
||||
}
|
||||
|
||||
return data('Pre-auth key created');
|
||||
}
|
||||
case 'expire_preauthkey': {
|
||||
const key = formData.get('key')?.toString();
|
||||
if (!key) {
|
||||
return data('Missing `key` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const expiry = formData.get("expiry")?.toString();
|
||||
if (!expiry) {
|
||||
return data("Missing `expiry` 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,
|
||||
});
|
||||
}
|
||||
const reusable = formData.get("reusable")?.toString();
|
||||
if (!reusable) {
|
||||
return data("Missing `reusable` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.expirePreAuthKey(user, key);
|
||||
return data('Pre-auth key expired');
|
||||
}
|
||||
default:
|
||||
return data('Invalid action', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const ephemeral = formData.get("ephemeral")?.toString();
|
||||
if (!ephemeral) {
|
||||
return data("Missing `ephemeral` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const day = Number(expiry.toString().split(" ")[0]);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + day);
|
||||
|
||||
const key = await api.createPreAuthKey(
|
||||
user,
|
||||
ephemeral === "on",
|
||||
reusable === "on",
|
||||
date,
|
||||
aclTags.length > 0 ? aclTags : null,
|
||||
);
|
||||
|
||||
return data({ success: true as const, key: key.key });
|
||||
}
|
||||
case "expire_preauthkey": {
|
||||
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 Button from '~/components/Button';
|
||||
import Code from '~/components/Code';
|
||||
import type { PreAuthKey, User } from '~/types';
|
||||
import toast from '~/utils/toast';
|
||||
import ExpireAuthKey from './dialogs/expire-auth-key';
|
||||
import Attribute from "~/components/Attribute";
|
||||
import type { PreAuthKey, User } from "~/types";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
import ExpireAuthKey from "./dialogs/expire-auth-key";
|
||||
|
||||
interface Props {
|
||||
authKey: PreAuthKey;
|
||||
user: User;
|
||||
url: string;
|
||||
authKey: PreAuthKey;
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
export default function AuthKeyRow({ authKey, user, url }: Props) {
|
||||
const createdAt = new Date(authKey.createdAt).toLocaleString();
|
||||
const expiration = new Date(authKey.expiration).toLocaleString();
|
||||
export default function AuthKeyRow({ authKey, user }: Props) {
|
||||
const createdAt = new Date(authKey.createdAt).toLocaleString();
|
||||
const expiration = new Date(authKey.expiration).toLocaleString();
|
||||
const isExpired =
|
||||
(authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date();
|
||||
const userDisplay = user ? getUserDisplayName(user) : "(Tag Only)";
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Attribute isCopyable name="Key" value={authKey.key} />
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="User"
|
||||
value={user.name || user.displayName || user.email || user.id}
|
||||
/>
|
||||
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
|
||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
|
||||
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
|
||||
<Attribute name="Created" value={createdAt} />
|
||||
<Attribute name="Expiration" value={expiration} />
|
||||
<p className="mb-1 mt-4">
|
||||
To use this key, run the following command on your device:
|
||||
</p>
|
||||
<Code className="text-sm">
|
||||
tailscale up --login-server={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>
|
||||
);
|
||||
return (
|
||||
<div className="w-full">
|
||||
<Attribute name="Key" value={authKey.key} />
|
||||
<Attribute name="User" value={userDisplay} />
|
||||
<Attribute name="Reusable" value={authKey.reusable ? "Yes" : "No"} />
|
||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? "Yes" : "No"} />
|
||||
<Attribute name="Used" value={authKey.used ? "Yes" : "No"} />
|
||||
<Attribute name="Created" value={createdAt} />
|
||||
<Attribute name="Expiration" value={expiration} />
|
||||
{!isExpired && user && (
|
||||
<div className="mt-2" suppressHydrationWarning>
|
||||
<ExpireAuthKey authKey={authKey} user={user} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,101 +1,224 @@
|
||||
import { Key, useState } from 'react';
|
||||
import Dialog from '~/components/Dialog';
|
||||
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 { Key, useEffect, useRef, useState } from "react";
|
||||
import { useFetcher } from "react-router";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Code from "~/components/Code";
|
||||
import Dialog from "~/components/Dialog";
|
||||
import Input from "~/components/Input";
|
||||
import Link from "~/components/Link";
|
||||
import NumberInput from "~/components/NumberInput";
|
||||
import Select from "~/components/Select";
|
||||
import Switch from "~/components/Switch";
|
||||
import type { User } from "~/types";
|
||||
import toast from "~/utils/toast";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
interface AddAuthKeyProps {
|
||||
users: User[];
|
||||
users: User[];
|
||||
url: string;
|
||||
selfServiceOnly: boolean;
|
||||
currentSubject?: string;
|
||||
}
|
||||
|
||||
// TODO: Tags
|
||||
export default function AddAuthKey(data: AddAuthKeyProps) {
|
||||
const [reusable, setReusable] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const [userId, setUserId] = useState<Key | null>(data.users[0]?.id);
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Generate auth key</Dialog.Title>
|
||||
<input name="action_id" type="hidden" value="add_preauthkey" />
|
||||
<input name="user_id" type="hidden" value={userId?.toString()} />
|
||||
<Select
|
||||
className="mb-2"
|
||||
description="This is the user machines will belong to when they authenticate."
|
||||
isRequired
|
||||
label="User"
|
||||
name="user"
|
||||
onSelectionChange={(value) => {
|
||||
setUserId(value);
|
||||
}}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<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} // 1000 years
|
||||
minValue={1}
|
||||
name="expiry"
|
||||
/>
|
||||
<div className="flex justify-between items-center gap-2 mt-6">
|
||||
<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"
|
||||
name="reusable"
|
||||
onChange={() => {
|
||||
setReusable(!reusable);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input name="reusable" type="hidden" value={reusable.toString()} />
|
||||
<div className="flex justify-between items-center gap-2 mt-6">
|
||||
<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"
|
||||
name="ephemeral"
|
||||
onChange={() => {
|
||||
setEphemeral(!ephemeral);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input name="ephemeral" type="hidden" value={ephemeral.toString()} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
function findCurrentUser(users: User[], subject: string | undefined): User | undefined {
|
||||
if (!subject) return undefined;
|
||||
return users.find((u) => {
|
||||
if (u.provider !== "oidc" || !u.providerId) return false;
|
||||
return u.providerId.split("/").pop() === subject;
|
||||
});
|
||||
}
|
||||
|
||||
export default function AddAuthKey({
|
||||
users,
|
||||
url,
|
||||
selfServiceOnly,
|
||||
currentSubject,
|
||||
}: AddAuthKeyProps) {
|
||||
const fetcher = useFetcher();
|
||||
const submittingRef = useRef(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [reusable, setReusable] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const [tagOnly, setTagOnly] = useState(false);
|
||||
const currentUser = selfServiceOnly ? findCurrentUser(users, currentSubject) : null;
|
||||
const availableUsers = selfServiceOnly && currentUser ? [currentUser] : users;
|
||||
const [userId, setUserId] = useState<Key | null>(availableUsers[0]?.id);
|
||||
const [tags, setTags] = useState("");
|
||||
|
||||
const createdKey = fetcher.data?.success ? fetcher.data.key : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (fetcher.state === "idle" && fetcher.data) {
|
||||
submittingRef.current = false;
|
||||
}
|
||||
}, [fetcher.data, fetcher.state]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setReusable(false);
|
||||
setEphemeral(false);
|
||||
setTagOnly(false);
|
||||
setUserId(availableUsers[0]?.id);
|
||||
setTags("");
|
||||
fetcher.data = undefined;
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const parsedTags = tags
|
||||
.split(",")
|
||||
.map((t) => t.trim())
|
||||
.filter((t) => t.length > 0)
|
||||
.map((t) => (t.startsWith("tag:") ? t : `tag:${t}`));
|
||||
|
||||
const canSubmit = tagOnly ? parsedTags.length > 0 : userId != null;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && submittingRef.current) return;
|
||||
setIsOpen(open);
|
||||
}}
|
||||
>
|
||||
<Button className="my-4" onPress={() => setIsOpen(true)}>
|
||||
Create pre-auth key
|
||||
</Button>
|
||||
{createdKey ? (
|
||||
<Dialog.Panel variant="unactionable">
|
||||
<Dialog.Title>Pre-auth key created</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
Copy this key now. You will not be able to see the full key again.
|
||||
</Dialog.Text>
|
||||
<div className="bg-headplane-100 dark:bg-headplane-800 mt-4 flex items-center gap-2 rounded-lg px-3 py-2">
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-sm">{createdKey}</code>
|
||||
<Button
|
||||
className="shrink-0"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(createdKey);
|
||||
toast("Copied key to clipboard");
|
||||
}}
|
||||
variant="light"
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<Dialog.Text className="mt-4 text-sm">To register a device with this key:</Dialog.Text>
|
||||
<Code isCopyable className="mt-1 block text-sm">
|
||||
{`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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,245 +1,284 @@
|
||||
import { FileKey2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Link as RemixLink } 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 { Capabilities } from '~/server/web/roles';
|
||||
import log from '~/utils/log';
|
||||
import type { Route } from './+types/overview';
|
||||
import { authKeysAction } from './actions';
|
||||
import AuthKeyRow from './auth-key-row';
|
||||
import AddAuthKey from './dialogs/add-auth-key';
|
||||
import { FileKey2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link as RemixLink } 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 { Capabilities } from "~/server/web/roles";
|
||||
import type { PreAuthKey } from "~/types";
|
||||
import type { User } from "~/types/User";
|
||||
import log from "~/utils/log";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
import { authKeysAction } from "./actions";
|
||||
import AuthKeyRow from "./auth-key-row";
|
||||
import AddAuthKey from "./dialogs/add-auth-key";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const principal = await context.auth.require(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
const users = await api.getUsers();
|
||||
const preAuthKeys = await Promise.all(
|
||||
users
|
||||
.filter((user) => user.name?.length > 0) // Filter out any invalid users
|
||||
.map(async (user) => {
|
||||
try {
|
||||
const preAuthKeys = await api.getPreAuthKeys(user.id);
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
preAuthKeys,
|
||||
};
|
||||
} catch (error) {
|
||||
log.error('api', 'GET /v1/preauthkey for %s: %o', user.name, error);
|
||||
return {
|
||||
success: false,
|
||||
user,
|
||||
error,
|
||||
preAuthKeys: [],
|
||||
};
|
||||
}
|
||||
}),
|
||||
);
|
||||
const users = await api.getUsers();
|
||||
|
||||
const keys = preAuthKeys
|
||||
.filter(({ success }) => success)
|
||||
.map(({ user, preAuthKeys }) => ({
|
||||
user,
|
||||
preAuthKeys,
|
||||
}));
|
||||
let keys: { user: User | null; preAuthKeys: PreAuthKey[] }[];
|
||||
let missing: { user: User; error: unknown }[] = [];
|
||||
|
||||
const missing = preAuthKeys
|
||||
.filter(({ success }) => !success)
|
||||
.map(({ user, error }) => ({
|
||||
user,
|
||||
error,
|
||||
}));
|
||||
// Try fetching all keys at once (Headscale 0.28+), fall back to per-user
|
||||
let allKeys: PreAuthKey[] | null = null;
|
||||
try {
|
||||
allKeys = await api.getAllPreAuthKeys();
|
||||
} catch {
|
||||
// older versions don't support this endpoint
|
||||
}
|
||||
|
||||
return {
|
||||
keys,
|
||||
missing,
|
||||
users,
|
||||
access: await context.sessions.check(
|
||||
request,
|
||||
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 const action = authKeysAction;
|
||||
|
||||
type Status = 'all' | 'active' | 'expired' | 'reusable' | 'ephemeral';
|
||||
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
|
||||
export default function Page({
|
||||
loaderData: { keys, missing, users, url, access },
|
||||
loaderData: { keys, missing, users, url, access, selfServiceOnly, currentSubject },
|
||||
}: Route.ComponentProps) {
|
||||
const [selectedUser, setSelectedUser] = useState('__headplane_all');
|
||||
const [status, setStatus] = useState<Status>('active');
|
||||
const isDisabled =
|
||||
!access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0;
|
||||
const [selectedUser, setSelectedUser] = useState("__headplane_all");
|
||||
const [status, setStatus] = useState<Status>("active");
|
||||
const isDisabled = !access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0;
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
const now = new Date();
|
||||
return keys
|
||||
.filter(({ user }) => {
|
||||
if (selectedUser === '__headplane_all') {
|
||||
return true;
|
||||
}
|
||||
const filteredKeys = useMemo(() => {
|
||||
const now = new Date();
|
||||
return keys
|
||||
.filter(({ user }) => {
|
||||
if (selectedUser === "__headplane_all") {
|
||||
return true;
|
||||
}
|
||||
|
||||
return user.id === selectedUser;
|
||||
})
|
||||
.flatMap(({ preAuthKeys }) => preAuthKeys)
|
||||
.filter((key) => {
|
||||
if (status === 'all') {
|
||||
return true;
|
||||
}
|
||||
if (selectedUser === "__headplane_tag_only") {
|
||||
return user === null;
|
||||
}
|
||||
|
||||
if (status === 'ephemeral') {
|
||||
return key.ephemeral;
|
||||
}
|
||||
return user?.id === selectedUser;
|
||||
})
|
||||
.flatMap(({ preAuthKeys }) => preAuthKeys)
|
||||
.filter((key) => {
|
||||
if (status === "all") {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (status === 'reusable') {
|
||||
return key.reusable;
|
||||
}
|
||||
if (status === "ephemeral") {
|
||||
return key.ephemeral;
|
||||
}
|
||||
|
||||
const expiry = new Date(key.expiration);
|
||||
if (status === 'expired') {
|
||||
// Expired keys are either used or expired
|
||||
// BUT only used if they are not reusable
|
||||
if (key.used && !key.reusable) {
|
||||
return true;
|
||||
}
|
||||
if (status === "reusable") {
|
||||
return key.reusable;
|
||||
}
|
||||
|
||||
return expiry < now;
|
||||
}
|
||||
const expiry = new Date(key.expiration);
|
||||
if (status === "expired") {
|
||||
// Expired keys are either used or expired
|
||||
// BUT only used if they are not reusable
|
||||
if (key.used && !key.reusable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (status === 'active') {
|
||||
// Active keys are either not expired or reusable
|
||||
if (expiry < now) {
|
||||
return false;
|
||||
}
|
||||
return expiry < now;
|
||||
}
|
||||
|
||||
if (!key.used) {
|
||||
return true;
|
||||
}
|
||||
if (status === "active") {
|
||||
// Active keys are either not expired or reusable
|
||||
if (expiry < now) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return key.reusable;
|
||||
}
|
||||
if (!key.used) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}, [keys, selectedUser, status]);
|
||||
return key.reusable;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:w-2/3">
|
||||
<p className="mb-8 text-md">
|
||||
<RemixLink className="font-medium" to="/settings">
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Pre-Auth Keys
|
||||
</p>
|
||||
{!access ? (
|
||||
<Notice title="Pre-auth key permissions restricted" variant="warning">
|
||||
You do not have the necessary permissions to generate pre-auth keys.
|
||||
Please contact your administrator to request access or to generate a
|
||||
pre-auth key for you.
|
||||
</Notice>
|
||||
) : missing.length > 0 ? (
|
||||
<Notice title="Missing authentication keys" variant="error">
|
||||
An error occurred while fetching the authentication keys for the
|
||||
following users:{' '}
|
||||
{missing.map(({ user }, index) => (
|
||||
<>
|
||||
<Code key={user.name}>{user.name}</Code>
|
||||
{index < missing.length - 1 ? ', ' : '. '}
|
||||
</>
|
||||
))}
|
||||
Their keys may not be listed correctly. Please check the server logs
|
||||
for more information.
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1>
|
||||
<p className="mb-4">
|
||||
Headscale fully supports pre-authentication keys in order to easily add
|
||||
devices to your Tailnet. To learn more about using pre-authentication
|
||||
keys, visit the{' '}
|
||||
<Link
|
||||
name="Tailscale Auth Keys documentation"
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
</p>
|
||||
<AddAuthKey users={users} />
|
||||
<div className="flex items-center gap-4 mt-4">
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultSelectedKey="__headplane_all"
|
||||
isDisabled={isDisabled}
|
||||
label="User"
|
||||
onSelectionChange={(value) =>
|
||||
setSelectedUser(value?.toString() ?? '')
|
||||
}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{[
|
||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||
...keys.map(({ user }) => (
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</Select.Item>
|
||||
)),
|
||||
]}
|
||||
</Select>
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultSelectedKey="active"
|
||||
isDisabled={isDisabled}
|
||||
label="Status"
|
||||
onSelectionChange={(value) =>
|
||||
setStatus((value?.toString() ?? 'active') as Status)
|
||||
}
|
||||
placeholder="Select a status"
|
||||
>
|
||||
<Select.Item key="all">All</Select.Item>
|
||||
<Select.Item key="active">Active</Select.Item>
|
||||
<Select.Item key="expired">Used/Expired</Select.Item>
|
||||
<Select.Item key="reusable">Reusable</Select.Item>
|
||||
<Select.Item key="ephemeral">Ephemeral</Select.Item>
|
||||
</Select>
|
||||
</div>
|
||||
<TableList className="mt-4">
|
||||
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<FileKey2 />
|
||||
<p className="font-semibold">
|
||||
No pre-auth keys have been created yet.
|
||||
</p>
|
||||
</TableList.Item>
|
||||
) : filteredKeys.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<FileKey2 />
|
||||
<p className="font-semibold">
|
||||
No pre-auth keys match the selected filters.
|
||||
</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
filteredKeys.map((key) => {
|
||||
// TODO: Why is Headscale using email as the user ID here?
|
||||
// https://github.com/juanfont/headscale/issues/2520
|
||||
const user = users.find((user) => user.id === key.user.id);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}, [keys, selectedUser, status]);
|
||||
|
||||
return (
|
||||
<TableList.Item key={key.id}>
|
||||
<AuthKeyRow authKey={key} url={url} user={user} />
|
||||
</TableList.Item>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableList>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex flex-col md:w-2/3">
|
||||
<p className="text-md mb-8">
|
||||
<RemixLink className="font-medium" to="/settings">
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Pre-Auth Keys
|
||||
</p>
|
||||
{!access ? (
|
||||
<Notice title="Pre-auth key permissions restricted" variant="warning">
|
||||
You do not have the necessary permissions to generate pre-auth keys. Please contact your
|
||||
administrator to request access or to generate a pre-auth key for you.
|
||||
</Notice>
|
||||
) : missing.length > 0 ? (
|
||||
<Notice title="Missing authentication keys" variant="error">
|
||||
An error occurred while fetching the authentication keys for the following users:{" "}
|
||||
{missing.map(({ user }, index) => (
|
||||
<>
|
||||
<Code key={user.id}>{getUserDisplayName(user)}</Code>
|
||||
{index < missing.length - 1 ? ", " : ". "}
|
||||
</>
|
||||
))}
|
||||
Their keys may not be listed correctly. Please check the server logs for more information.
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<h1 className="mb-2 text-2xl font-medium">Pre-Auth Keys</h1>
|
||||
<p className="mb-4">
|
||||
Headscale fully supports pre-authentication keys in order to easily add devices to your
|
||||
Tailnet. To learn more about using pre-authentication keys, visit the{" "}
|
||||
<Link
|
||||
name="Tailscale Auth Keys documentation"
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
</p>
|
||||
<AddAuthKey
|
||||
currentSubject={currentSubject}
|
||||
selfServiceOnly={selfServiceOnly}
|
||||
url={url}
|
||||
users={users}
|
||||
/>
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultSelectedKey="__headplane_all"
|
||||
isDisabled={isDisabled}
|
||||
label="User"
|
||||
onSelectionChange={(value) => setSelectedUser(value?.toString() ?? "")}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{[
|
||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||
...keys
|
||||
.filter((k): k is { user: User; preAuthKeys: PreAuthKey[] } => k.user !== null)
|
||||
.map(({ user }) => (
|
||||
<Select.Item key={user.id}>{getUserDisplayName(user)}</Select.Item>
|
||||
)),
|
||||
...(keys.some(({ user }) => user === null)
|
||||
? [<Select.Item key="__headplane_tag_only">Tag Only</Select.Item>]
|
||||
: []),
|
||||
]}
|
||||
</Select>
|
||||
<Select
|
||||
className="w-full"
|
||||
defaultSelectedKey="active"
|
||||
isDisabled={isDisabled}
|
||||
label="Status"
|
||||
onSelectionChange={(value) => setStatus((value?.toString() ?? "active") as Status)}
|
||||
placeholder="Select a status"
|
||||
>
|
||||
<Select.Item key="all">All</Select.Item>
|
||||
<Select.Item key="active">Active</Select.Item>
|
||||
<Select.Item key="expired">Used/Expired</Select.Item>
|
||||
<Select.Item key="reusable">Reusable</Select.Item>
|
||||
<Select.Item key="ephemeral">Ephemeral</Select.Item>
|
||||
</Select>
|
||||
</div>
|
||||
<TableList className="mt-4">
|
||||
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<FileKey2 />
|
||||
<p className="font-semibold">No pre-auth keys have been created yet.</p>
|
||||
</TableList.Item>
|
||||
) : filteredKeys.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
<FileKey2 />
|
||||
<p className="font-semibold">No pre-auth keys match the selected filters.</p>
|
||||
</TableList.Item>
|
||||
) : (
|
||||
filteredKeys.map((key) => {
|
||||
// Tag-only keys have no user
|
||||
if (!key.user) {
|
||||
return (
|
||||
<TableList.Item key={key.id}>
|
||||
<AuthKeyRow authKey={key} user={null} />
|
||||
</TableList.Item>
|
||||
);
|
||||
}
|
||||
|
||||
// TODO: Why is Headscale using email as the user ID here?
|
||||
// https://github.com/juanfont/headscale/issues/2520
|
||||
const user = users.find((user) => user.id === key.user?.id);
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableList.Item key={key.id}>
|
||||
<AuthKeyRow authKey={key} user={user} />
|
||||
</TableList.Item>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</TableList>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,76 +1,73 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import Link from '~/components/Link';
|
||||
import type { Route } from './+types/overview';
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { Link as RemixLink } from "react-router";
|
||||
|
||||
import Link from "~/components/Link";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function loader({ context }: Route.LoaderArgs) {
|
||||
return {
|
||||
config: context.hs.writable(),
|
||||
isOidcEnabled: context.oidcConnector?.isValid ?? false,
|
||||
};
|
||||
const oidcConnector = await context.oidc?.connector.get();
|
||||
return {
|
||||
config: context.hs.writable(),
|
||||
isOidcEnabled: oidcConnector?.isValid ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page({
|
||||
loaderData: { config, isOidcEnabled },
|
||||
}: Route.ComponentProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-(--breakpoint-lg)">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Settings</h1>
|
||||
<p>
|
||||
The settings page is still under construction. As I'm able to add more
|
||||
features, I'll be adding them here. If you require any features, feel
|
||||
free to open an issue on the GitHub repository.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
|
||||
<p>
|
||||
Headscale fully supports pre-authentication keys in order to easily
|
||||
add devices to your Tailnet. To learn more about using
|
||||
pre-authentication keys, visit the{' '}
|
||||
<Link
|
||||
name="Tailscale Auth Keys documentation"
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RemixLink to="/settings/auth-keys">
|
||||
<div className="text-lg font-medium flex items-center">
|
||||
Manage Auth Keys
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</div>
|
||||
</RemixLink>
|
||||
{config && isOidcEnabled ? (
|
||||
<>
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">
|
||||
Authentication Restrictions
|
||||
</h1>
|
||||
<p>
|
||||
Headscale supports restricting OIDC authentication to only allow
|
||||
certain email domains, groups, or users to authenticate. This can
|
||||
be used to limit access to your Tailnet to only certain users or
|
||||
groups and Headplane will also respect these settings when
|
||||
authenticating.{' '}
|
||||
<Link
|
||||
name="Headscale OIDC documentation"
|
||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RemixLink to="/settings/restrictions">
|
||||
<div className="text-lg font-medium flex items-center">
|
||||
Manage Restrictions
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</div>
|
||||
</RemixLink>
|
||||
</>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
export default function Page({ loaderData: { config, isOidcEnabled } }: Route.ComponentProps) {
|
||||
return (
|
||||
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
|
||||
<div className="flex w-full flex-col sm:w-2/3">
|
||||
<h1 className="mb-4 text-2xl font-medium">Settings</h1>
|
||||
<p>
|
||||
The settings page is still under construction. As I'm able to add more features, I'll be
|
||||
adding them here. If you require any features, feel free to open an issue on the GitHub
|
||||
repository.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-col sm:w-2/3">
|
||||
<h1 className="mb-4 text-2xl font-medium">Pre-Auth Keys</h1>
|
||||
<p>
|
||||
Headscale fully supports pre-authentication keys in order to easily add devices to your
|
||||
Tailnet. To learn more about using pre-authentication keys, visit the{" "}
|
||||
<Link
|
||||
name="Tailscale Auth Keys documentation"
|
||||
to="https://tailscale.com/kb/1085/auth-keys/"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RemixLink to="/settings/auth-keys">
|
||||
<div className="flex items-center text-lg font-medium">
|
||||
Manage Auth Keys
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</div>
|
||||
</RemixLink>
|
||||
{config && isOidcEnabled ? (
|
||||
<>
|
||||
<div className="flex w-full flex-col sm:w-2/3">
|
||||
<h1 className="mb-4 text-2xl font-medium">Authentication Restrictions</h1>
|
||||
<p>
|
||||
Headscale supports restricting OIDC authentication to only allow certain email
|
||||
domains, groups, or users to authenticate. This can be used to limit access to your
|
||||
Tailnet to only certain users or groups and Headplane will also respect these settings
|
||||
when authenticating.{" "}
|
||||
<Link
|
||||
name="Headscale OIDC documentation"
|
||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RemixLink to="/settings/restrictions">
|
||||
<div className="flex items-center text-lg font-medium">
|
||||
Manage Restrictions
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</div>
|
||||
</RemixLink>
|
||||
</>
|
||||
) : undefined}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,198 +1,189 @@
|
||||
import { data } from 'react-router';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
import { data } from "react-router";
|
||||
|
||||
export async function restrictionAction({
|
||||
request,
|
||||
context,
|
||||
}: Route.ActionArgs) {
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.configure_iam,
|
||||
);
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
if (!check) {
|
||||
throw data('You do not have permission to modify IAM settings.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
if (!context.hs.writable()) {
|
||||
throw data('The Headscale configuration file is not editable.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
export async function restrictionAction({ request, context }: Route.ActionArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.configure_iam);
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('No action provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!check) {
|
||||
throw data("You do not have permission to modify IAM settings.", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
// We only need healthchecks which don't rely on an API key
|
||||
const api = context.hsApi.getRuntimeClient('fake-api-key');
|
||||
switch (action) {
|
||||
case 'add_domain': {
|
||||
const domain = formData.get('domain')?.toString()?.trim();
|
||||
if (!domain) {
|
||||
throw data('No domain provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!context.hs.writable()) {
|
||||
throw data("The Headscale configuration file is not editable.", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const domains = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
|
||||
];
|
||||
const formData = await request.formData();
|
||||
const action = formData.get("action_id")?.toString();
|
||||
if (!action) {
|
||||
throw data("No action provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
// We only need healthchecks which don't rely on an API key
|
||||
const api = context.hsApi.getRuntimeClient("fake-api-key");
|
||||
switch (action) {
|
||||
case "add_domain": {
|
||||
const domain = formData.get("domain")?.toString()?.trim();
|
||||
if (!domain) {
|
||||
throw data("No domain provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('Domain added successfully.');
|
||||
}
|
||||
const domains = [...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain])];
|
||||
|
||||
case 'remove_domain': {
|
||||
const domain = formData.get('domain')?.toString()?.trim();
|
||||
if (!domain) {
|
||||
throw data('No domain provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_domains",
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
|
||||
if (!storedDomains.includes(domain)) {
|
||||
// Domain not found in the list
|
||||
throw data(`Domain "${domain}" not found in allowed domains.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("Domain added successfully.");
|
||||
}
|
||||
|
||||
// Filter out the domain to remove it from the list
|
||||
const domains = storedDomains.filter((d: string) => d !== domain);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('Domain removed successfully.');
|
||||
}
|
||||
case "remove_domain": {
|
||||
const domain = formData.get("domain")?.toString()?.trim();
|
||||
if (!domain) {
|
||||
throw data("No domain provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
case 'add_group': {
|
||||
const group = formData.get('group')?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data('No group provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
|
||||
if (!storedDomains.includes(domain)) {
|
||||
// Domain not found in the list
|
||||
throw data(`Domain "${domain}" not found in allowed domains.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const groups = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
|
||||
];
|
||||
// Filter out the domain to remove it from the list
|
||||
const domains = storedDomains.filter((d: string) => d !== domain);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_domains",
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("Domain removed successfully.");
|
||||
}
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_groups',
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
case "add_group": {
|
||||
const group = formData.get("group")?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data("No group provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('Group added successfully.');
|
||||
}
|
||||
const groups = [...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group])];
|
||||
|
||||
case 'remove_group': {
|
||||
const group = formData.get('group')?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data('No group provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_groups",
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
|
||||
if (!storedGroups.includes(group)) {
|
||||
// Group not found in the list
|
||||
throw data(`Group "${group}" not found in allowed groups.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("Group added successfully.");
|
||||
}
|
||||
|
||||
// Filter out the group to remove it from the list
|
||||
const groups = storedGroups.filter((d: string) => d !== group);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_groups',
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
case "remove_group": {
|
||||
const group = formData.get("group")?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data("No group provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('Group removed successfully.');
|
||||
}
|
||||
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
|
||||
if (!storedGroups.includes(group)) {
|
||||
// Group not found in the list
|
||||
throw data(`Group "${group}" not found in allowed groups.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
case 'add_user': {
|
||||
const user = formData.get('user')?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data('No user provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
// Filter out the group to remove it from the list
|
||||
const groups = storedGroups.filter((d: string) => d !== group);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_groups",
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
const users = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
|
||||
];
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("Group removed successfully.");
|
||||
}
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_users',
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
case "add_user": {
|
||||
const user = formData.get("user")?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data("No user provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('User added successfully.');
|
||||
}
|
||||
const users = [...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user])];
|
||||
|
||||
case 'remove_user': {
|
||||
const user = formData.get('user')?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data('No user provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_users",
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
|
||||
if (!storedUsers.includes(user)) {
|
||||
// User not found in the list
|
||||
throw data(`User "${user}" not found in allowed users.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("User added successfully.");
|
||||
}
|
||||
|
||||
// Filter out the user to remove it from the list
|
||||
const users = storedUsers.filter((d: string) => d !== user);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_users',
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
case "remove_user": {
|
||||
const user = formData.get("user")?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data("No user provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('User removed successfully.');
|
||||
}
|
||||
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
|
||||
if (!storedUsers.includes(user)) {
|
||||
// User not found in the list
|
||||
throw data(`User "${user}" not found in allowed users.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
default: {
|
||||
throw data('Invalid action provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Filter out the user to remove it from the list
|
||||
const users = storedUsers.filter((d: string) => d !== user);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: "oidc.allowed_users",
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data("User removed successfully.");
|
||||
}
|
||||
|
||||
default: {
|
||||
throw data("Invalid action provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +1,91 @@
|
||||
import { data, Link as RemixLink } from 'react-router';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
import { restrictionAction } from './actions';
|
||||
import AddDomain from './dialogs/add-domain';
|
||||
import AddGroup from './dialogs/add-group';
|
||||
import AddUser from './dialogs/add-user';
|
||||
import RestrictionTable from './table';
|
||||
import { data, Link as RemixLink } from "react-router";
|
||||
|
||||
import Link from "~/components/Link";
|
||||
import Notice from "~/components/Notice";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
import { restrictionAction } from "./actions";
|
||||
import AddDomain from "./dialogs/add-domain";
|
||||
import AddGroup from "./dialogs/add-group";
|
||||
import AddUser from "./dialogs/add-user";
|
||||
import RestrictionTable from "./table";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const check = await context.sessions.check(request, Capabilities.read_users);
|
||||
if (!check) {
|
||||
throw data('You do not have permission to view IAM settings.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const principal = await context.auth.require(request);
|
||||
const check = context.auth.can(principal, Capabilities.read_users);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to view IAM settings.", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
if (!context.hs.c?.oidc) {
|
||||
throw data('OIDC is not configured on this Headscale instance.', {
|
||||
status: 501,
|
||||
});
|
||||
}
|
||||
if (!context.hs.c?.oidc) {
|
||||
throw data("OIDC is not configured on this Headscale instance.", {
|
||||
status: 501,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
access: await context.sessions.check(request, Capabilities.configure_iam),
|
||||
writable: context.hs.writable(),
|
||||
settings: {
|
||||
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
|
||||
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
|
||||
users: [...new Set(context.hs.c.oidc.allowed_users)],
|
||||
},
|
||||
};
|
||||
return {
|
||||
access: context.auth.can(principal, Capabilities.configure_iam),
|
||||
writable: context.hs.writable(),
|
||||
settings: {
|
||||
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
|
||||
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
|
||||
users: [...new Set(context.hs.c.oidc.allowed_users)],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const action = restrictionAction;
|
||||
|
||||
export default function Page({
|
||||
loaderData: { access, writable, settings },
|
||||
}: Route.ComponentProps) {
|
||||
const isDisabled = writable ? !access : true;
|
||||
export default function Page({ loaderData: { access, writable, settings } }: Route.ComponentProps) {
|
||||
const isDisabled = writable ? !access : true;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<p className="mb-4 text-md">
|
||||
<RemixLink className="font-medium" to="/settings">
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Authentication Restrictions
|
||||
</p>
|
||||
{!access ? (
|
||||
<Notice
|
||||
title="Authentication permissions restricted"
|
||||
variant="warning"
|
||||
>
|
||||
You do not have the necessary permissions to edit the Authentication
|
||||
Restrictions settings. Please contact your administrator to request
|
||||
access or to make changes to these settings.
|
||||
</Notice>
|
||||
) : !writable ? (
|
||||
<Notice title="Configuration Locked" variant="error">
|
||||
The Headscale configuration file is not editable through the web
|
||||
interface. Please ensure that you have correctly given Headplane
|
||||
write access to the file.
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<h1 className="text-2xl font-medium mb-2 mt-4">
|
||||
Authentication Restrictions
|
||||
</h1>
|
||||
<p>
|
||||
Headscale supports restricting OIDC authentication to only allow
|
||||
certain email domains, groups, or users to authenticate. This can be
|
||||
used to limit access to your Tailnet to only certain users or groups
|
||||
and Headplane will also respect these settings when authenticating.{' '}
|
||||
<Link
|
||||
name="Headscale OIDC documentation"
|
||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RestrictionTable
|
||||
isDisabled={isDisabled}
|
||||
type="domain"
|
||||
values={settings.domains}
|
||||
>
|
||||
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
<RestrictionTable
|
||||
isDisabled={isDisabled}
|
||||
type="group"
|
||||
values={settings.groups}
|
||||
>
|
||||
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
<RestrictionTable
|
||||
isDisabled={isDisabled}
|
||||
type="user"
|
||||
values={settings.users}
|
||||
>
|
||||
<AddUser isDisabled={isDisabled} users={settings.users} />
|
||||
</RestrictionTable>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="flex max-w-(--breakpoint-lg) flex-col gap-4">
|
||||
<div className="flex w-full flex-col sm:w-2/3">
|
||||
<p className="text-md mb-4">
|
||||
<RemixLink className="font-medium" to="/settings">
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Authentication Restrictions
|
||||
</p>
|
||||
{!access ? (
|
||||
<Notice title="Authentication permissions restricted" variant="warning">
|
||||
You do not have the necessary permissions to edit the Authentication Restrictions
|
||||
settings. Please contact your administrator to request access or to make changes to
|
||||
these settings.
|
||||
</Notice>
|
||||
) : !writable ? (
|
||||
<Notice title="Configuration Locked" variant="error">
|
||||
The Headscale configuration file is not editable through the web interface. Please
|
||||
ensure that you have correctly given Headplane write access to the file.
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<h1 className="mt-4 mb-2 text-2xl font-medium">Authentication Restrictions</h1>
|
||||
<p>
|
||||
Headscale supports restricting OIDC authentication to only allow certain email domains,
|
||||
groups, or users to authenticate. This can be used to limit access to your Tailnet to only
|
||||
certain users or groups and Headplane will also respect these settings when
|
||||
authenticating.{" "}
|
||||
<Link
|
||||
name="Headscale OIDC documentation"
|
||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RestrictionTable isDisabled={isDisabled} type="domain" values={settings.domains}>
|
||||
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
<RestrictionTable isDisabled={isDisabled} type="group" values={settings.groups}>
|
||||
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
<RestrictionTable isDisabled={isDisabled} type="user" values={settings.users}>
|
||||
<AddUser isDisabled={isDisabled} users={settings.users} />
|
||||
</RestrictionTable>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default function RestrictionTable({
|
||||
isDisabled,
|
||||
}: RestrictionProps) {
|
||||
return (
|
||||
<div className="w-2/3">
|
||||
<div className="w-full sm:w-2/3">
|
||||
<h2 className="text-2xl font-medium mt-8">
|
||||
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
|
||||
</h2>
|
||||
@@ -61,7 +61,7 @@ export default function RestrictionTable({
|
||||
) : (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
{iconForType(type)}
|
||||
<p className="font-semibold">
|
||||
<p className="font-semibold text-center">
|
||||
All {type}s are permitted to authenticate.
|
||||
</p>
|
||||
</TableList.Item>
|
||||
|
||||
+220
-242
@@ -1,293 +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 { 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 { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import type { Route } from './+types/console';
|
||||
import UserPrompt from './user-prompt';
|
||||
import XTerm from './xterm.client';
|
||||
import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema";
|
||||
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
||||
import { useLiveData } from "~/utils/live-data";
|
||||
|
||||
import type { Route } from "./+types/console";
|
||||
import UserPrompt from "./user-prompt";
|
||||
import XTerm from "./xterm.client";
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = () => {
|
||||
return false;
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const origin = new URL(request.url).origin;
|
||||
const assets = ['/wasm_exec.js', '/hp_ssh.wasm'];
|
||||
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) {
|
||||
const res = await fetch(`${origin}${file}`, { method: 'HEAD' });
|
||||
if (!res.ok) missing.push(file);
|
||||
}
|
||||
for (const file of assets) {
|
||||
const res = await fetch(`${origin}${file}`, { method: "HEAD" });
|
||||
if (!res.ok) missing.push(file);
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw data('WebSSH is not configured in this build.', 405);
|
||||
}
|
||||
if (missing.length > 0) {
|
||||
throw data("WebSSH is not configured in this build.", 405);
|
||||
}
|
||||
|
||||
if (!context.agents?.agentID()) {
|
||||
throw data(
|
||||
'WebSSH is only available with the Headplane agent integration',
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (!context.agents?.agentID()) {
|
||||
throw data("WebSSH is only available with the Headplane agent integration", 400);
|
||||
}
|
||||
|
||||
const session = await context.sessions.auth(request);
|
||||
if (session.user.subject === 'unknown-non-oauth') {
|
||||
throw data('Only OAuth users are allowed to use WebSSH', 403);
|
||||
}
|
||||
const principal = await context.auth.require(request);
|
||||
if (principal.kind === "api_key") {
|
||||
throw data("Only OAuth users are allowed to use WebSSH", 403);
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const users = await api.getUsers();
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const users = await api.getUsers();
|
||||
|
||||
// MARK: This assumes that a user has authenticated with Headscale first
|
||||
// Since the only way to enforce permissions via ACLs is to generate a
|
||||
// pre-authkey which REQUIRES a user ID, meaning the user has to have
|
||||
// authenticated with Headscale first.
|
||||
const lookup = users.find((u) => {
|
||||
const subject = u.providerId?.split('/').pop();
|
||||
if (!subject) {
|
||||
return false;
|
||||
}
|
||||
return subject === session.user.subject;
|
||||
});
|
||||
// MARK: This assumes that a user has authenticated with Headscale first
|
||||
// Since the only way to enforce permissions via ACLs is to generate a
|
||||
// pre-authkey which REQUIRES a user ID, meaning the user has to have
|
||||
// authenticated with Headscale first.
|
||||
const lookup = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
|
||||
|
||||
if (!lookup) {
|
||||
throw data(
|
||||
`User with subject ${session.user.subject} not found within Headscale`,
|
||||
404,
|
||||
);
|
||||
}
|
||||
if (!lookup) {
|
||||
throw data(`User with subject ${principal.user.subject} not found within Headscale`, 404);
|
||||
}
|
||||
|
||||
const preAuthKey = await api.createPreAuthKey(
|
||||
lookup.id,
|
||||
true, // ephemeral
|
||||
false, // reusable
|
||||
new Date(Date.now() + 60 * 1000), // expiration: 1 minute
|
||||
null, // aclTags
|
||||
);
|
||||
const preAuthKey = await api.createPreAuthKey(
|
||||
lookup.id,
|
||||
true, // ephemeral
|
||||
false, // reusable
|
||||
new Date(Date.now() + 60 * 1000), // expiration: 1 minute
|
||||
null, // aclTags
|
||||
);
|
||||
|
||||
// TODO: Enable config to enforce generate_authkeys capability
|
||||
// For now, any user is capable of WebSSH connections
|
||||
// const check = await context.sessions.check(
|
||||
// request,
|
||||
// Capabilities.generate_authkeys,
|
||||
// );
|
||||
// TODO: Enable config to enforce generate_authkeys capability
|
||||
// For now, any user is capable of WebSSH connections
|
||||
// const check = await context.sessions.check(
|
||||
// request,
|
||||
// Capabilities.generate_authkeys,
|
||||
// );
|
||||
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const username = qp.get('username') || undefined;
|
||||
const hostname = qp.get('hostname') || undefined;
|
||||
if (!hostname) {
|
||||
throw data('Missing required parameter: hostname', 400);
|
||||
}
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const username = qp.get("username") || undefined;
|
||||
const hostname = qp.get("hostname") || undefined;
|
||||
if (!hostname) {
|
||||
throw data("Missing required parameter: hostname", 400);
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
return {
|
||||
ipnDetails: undefined,
|
||||
sshDetails: {
|
||||
username,
|
||||
hostname,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!username) {
|
||||
return {
|
||||
ipnDetails: undefined,
|
||||
sshDetails: {
|
||||
username,
|
||||
hostname,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// We're making a request to <url>/key?v=116 to check the CORS headers
|
||||
const u = context.config.headscale.public_url ?? context.config.headscale.url;
|
||||
// const res = await fetch(`${u}/key?v=116`, {
|
||||
// method: 'GET',
|
||||
// });
|
||||
// We're making a request to <url>/key?v=116 to check the CORS headers
|
||||
const u = context.config.headscale.public_url ?? context.config.headscale.url;
|
||||
// const res = await fetch(`${u}/key?v=116`, {
|
||||
// method: 'GET',
|
||||
// });
|
||||
|
||||
// const corsOrigin = res.headers.get('Access-Control-Allow-Origin');
|
||||
// const corsMethods = res.headers.get('Access-Control-Allow-Methods');
|
||||
// const corsHeaders = res.headers.get('Access-Control-Allow-Headers');
|
||||
// console.log(corsOrigin, corsMethods, corsHeaders);
|
||||
// const corsOrigin = res.headers.get('Access-Control-Allow-Origin');
|
||||
// const corsMethods = res.headers.get('Access-Control-Allow-Methods');
|
||||
// const corsHeaders = res.headers.get('Access-Control-Allow-Headers');
|
||||
// console.log(corsOrigin, corsMethods, corsHeaders);
|
||||
|
||||
// if (!corsOrigin || !corsMethods || !corsHeaders) {
|
||||
// throw data(
|
||||
// 'Headscale server does not have the required CORS headers for WebSSH',
|
||||
// 500,
|
||||
// );
|
||||
// }
|
||||
// if (!corsOrigin || !corsMethods || !corsHeaders) {
|
||||
// throw data(
|
||||
// 'Headscale server does not have the required CORS headers for WebSSH',
|
||||
// 500,
|
||||
// );
|
||||
// }
|
||||
|
||||
const nodes = await api.getNodes();
|
||||
const lookupNode = nodes.find((n) => n.givenName === hostname);
|
||||
if (!lookupNode) {
|
||||
throw data(`Node with hostname ${hostname} not found`, 404);
|
||||
}
|
||||
const nodes = await api.getNodes();
|
||||
const lookupNode = nodes.find((n) => n.givenName === hostname);
|
||||
if (!lookupNode) {
|
||||
throw data(`Node with hostname ${hostname} not found`, 404);
|
||||
}
|
||||
|
||||
// Last thing is keeping track of the ephemeral node in the database
|
||||
// because Headscale doesn't automatically delete ephemeral nodes???
|
||||
const [_ephemeralNode] = await context.db
|
||||
.insert(ephemeralNodes)
|
||||
.values({
|
||||
auth_key: preAuthKey.key,
|
||||
} satisfies EphemeralNodeInsert)
|
||||
.returning();
|
||||
// Last thing is keeping track of the ephemeral node in the database
|
||||
// because Headscale doesn't automatically delete ephemeral nodes???
|
||||
const [_ephemeralNode] = await context.db
|
||||
.insert(ephemeralNodes)
|
||||
.values({
|
||||
auth_key: preAuthKey.key,
|
||||
} satisfies EphemeralNodeInsert)
|
||||
.returning();
|
||||
|
||||
return {
|
||||
ipnDetails: {
|
||||
PreAuthKey: preAuthKey.key,
|
||||
Hostname: generateHostname(username),
|
||||
ControlURL: u,
|
||||
},
|
||||
return {
|
||||
ipnDetails: {
|
||||
PreAuthKey: preAuthKey.key,
|
||||
Hostname: generateHostname(username),
|
||||
ControlURL: u,
|
||||
},
|
||||
|
||||
sshDetails: {
|
||||
username,
|
||||
hostname,
|
||||
},
|
||||
};
|
||||
sshDetails: {
|
||||
username,
|
||||
hostname,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generateHostname(username: string) {
|
||||
const adjective = faker.word.adjective({
|
||||
length: {
|
||||
min: 3,
|
||||
max: 6,
|
||||
},
|
||||
});
|
||||
const adjective = faker.word.adjective({
|
||||
length: {
|
||||
min: 3,
|
||||
max: 6,
|
||||
},
|
||||
});
|
||||
|
||||
const noun = faker.word.noun({
|
||||
length: {
|
||||
min: 3,
|
||||
max: 6,
|
||||
},
|
||||
});
|
||||
const noun = faker.word.noun({
|
||||
length: {
|
||||
min: 3,
|
||||
max: 6,
|
||||
},
|
||||
});
|
||||
|
||||
return `ssh-${adjective}-${noun}-${username}`;
|
||||
return `ssh-${adjective}-${noun}-${username}`;
|
||||
}
|
||||
|
||||
export async function action({ request, context }: Route.ActionArgs) {
|
||||
await context.sessions.auth(request);
|
||||
if (!context.agents?.agentID()) {
|
||||
throw data(
|
||||
'WebSSH is only available with the Headplane agent integration',
|
||||
400,
|
||||
);
|
||||
}
|
||||
await context.auth.require(request);
|
||||
if (!context.agents?.agentID()) {
|
||||
throw data("WebSSH is only available with the Headplane agent integration", 400);
|
||||
}
|
||||
|
||||
const form = await request.formData();
|
||||
const nodeKey = form.get('node_key');
|
||||
const authKey = form.get('auth_key');
|
||||
const form = await request.formData();
|
||||
const nodeKey = form.get("node_key");
|
||||
const authKey = form.get("auth_key");
|
||||
|
||||
if (nodeKey === null || typeof nodeKey !== 'string') {
|
||||
throw data('Missing node_key', 400);
|
||||
}
|
||||
if (nodeKey === null || typeof nodeKey !== "string") {
|
||||
throw data("Missing node_key", 400);
|
||||
}
|
||||
|
||||
if (authKey === null || typeof authKey !== 'string') {
|
||||
throw data('Missing auth_key', 400);
|
||||
}
|
||||
if (authKey === null || typeof authKey !== "string") {
|
||||
throw data("Missing auth_key", 400);
|
||||
}
|
||||
|
||||
await context.db
|
||||
.update(ephemeralNodes)
|
||||
.set({
|
||||
node_key: nodeKey,
|
||||
})
|
||||
.where(eq(ephemeralNodes.auth_key, authKey));
|
||||
await context.db
|
||||
.update(ephemeralNodes)
|
||||
.set({
|
||||
node_key: nodeKey,
|
||||
})
|
||||
.where(eq(ephemeralNodes.auth_key, authKey));
|
||||
}
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{
|
||||
rel: 'preload',
|
||||
href: '/hp_ssh.wasm',
|
||||
as: 'fetch',
|
||||
type: 'application/wasm',
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
{
|
||||
rel: "preload",
|
||||
href: "/hp_ssh.wasm",
|
||||
as: "fetch",
|
||||
type: "application/wasm",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
];
|
||||
|
||||
export const handle: ExternalScriptsHandle = {
|
||||
scripts: [
|
||||
{
|
||||
src: '/wasm_exec.js',
|
||||
crossOrigin: 'anonymous',
|
||||
preload: true,
|
||||
},
|
||||
],
|
||||
scripts: [
|
||||
{
|
||||
src: "/wasm_exec.js",
|
||||
crossOrigin: "anonymous",
|
||||
preload: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function Page({
|
||||
loaderData: { ipnDetails, sshDetails },
|
||||
}: Route.ComponentProps) {
|
||||
const submit = useSubmit();
|
||||
const { pause } = useLiveData();
|
||||
export default function Page({ loaderData: { ipnDetails, sshDetails } }: Route.ComponentProps) {
|
||||
const submit = useSubmit();
|
||||
const { pause } = useLiveData();
|
||||
|
||||
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
||||
const [nodeKey, setNodeKey] = useState<string | null>(null);
|
||||
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
||||
const [nodeKey, setNodeKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ipnDetails) {
|
||||
return;
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!ipnDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
pause();
|
||||
const go = new Go(); // Go is defined by wasm_exec.js
|
||||
WebAssembly.instantiateStreaming(
|
||||
fetch('/hp_ssh.wasm'),
|
||||
go.importObject,
|
||||
).then((value) => {
|
||||
go.run(value.instance);
|
||||
const handle = TsWasmNet(ipnDetails, {
|
||||
NotifyState: (state) => {
|
||||
console.log('State changed:', state);
|
||||
if (state === 'Running') {
|
||||
setIpn(handle);
|
||||
}
|
||||
},
|
||||
NotifyNetMap: (netmap) => {
|
||||
// Only set NodeKey if it is not already set and then
|
||||
// also dispatch that to the backend to track the
|
||||
// ephemeral node.
|
||||
//
|
||||
// We open an SSE connection to the backend
|
||||
// so that when the connection is closed,
|
||||
// the backend can delete the ephemeral node.
|
||||
if (nodeKey === null) {
|
||||
setNodeKey(netmap.NodeKey);
|
||||
submit(
|
||||
{
|
||||
node_key: netmap.NodeKey,
|
||||
auth_key: ipnDetails.PreAuthKey,
|
||||
},
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
},
|
||||
NotifyBrowseToURL: (url) => {
|
||||
console.log('Browse to URL:', url);
|
||||
},
|
||||
NotifyPanicRecover: (message) => {
|
||||
console.error('Panic recover:', message);
|
||||
},
|
||||
});
|
||||
pause();
|
||||
const go = new Go(); // Go is defined by wasm_exec.js
|
||||
WebAssembly.instantiateStreaming(fetch("/hp_ssh.wasm"), go.importObject).then((value) => {
|
||||
go.run(value.instance);
|
||||
const handle = TsWasmNet(ipnDetails, {
|
||||
NotifyState: (state) => {
|
||||
console.log("State changed:", state);
|
||||
if (state === "Running") {
|
||||
setIpn(handle);
|
||||
}
|
||||
},
|
||||
NotifyNetMap: (netmap) => {
|
||||
// Only set NodeKey if it is not already set and then
|
||||
// also dispatch that to the backend to track the
|
||||
// ephemeral node.
|
||||
//
|
||||
// We open an SSE connection to the backend
|
||||
// so that when the connection is closed,
|
||||
// the backend can delete the ephemeral node.
|
||||
if (nodeKey === null) {
|
||||
setNodeKey(netmap.NodeKey);
|
||||
submit(
|
||||
{
|
||||
node_key: netmap.NodeKey,
|
||||
auth_key: ipnDetails.PreAuthKey,
|
||||
},
|
||||
{ method: "POST" },
|
||||
);
|
||||
}
|
||||
},
|
||||
NotifyBrowseToURL: (url) => {
|
||||
console.log("Browse to URL:", url);
|
||||
},
|
||||
NotifyPanicRecover: (message) => {
|
||||
console.error("Panic recover:", message);
|
||||
},
|
||||
});
|
||||
|
||||
handle.Start();
|
||||
});
|
||||
}, []);
|
||||
handle.Start();
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!sshDetails.username) {
|
||||
return <UserPrompt hostname={sshDetails.hostname} />;
|
||||
}
|
||||
if (!sshDetails.username) {
|
||||
return <UserPrompt hostname={sshDetails.hostname} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-screen h-screen bg-headplane-900">
|
||||
{ipn === null ? (
|
||||
<div className="mx-auto h-screen flex items-center justify-center">
|
||||
<Loader2 className="animate-spin size-10 text-headplane-50" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col h-screen">
|
||||
<XTerm
|
||||
hostname={sshDetails.hostname}
|
||||
ipn={ipn}
|
||||
username={sshDetails.username}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="bg-headplane-900 h-screen w-screen">
|
||||
{ipn === null ? (
|
||||
<div className="mx-auto flex h-screen items-center justify-center">
|
||||
<Loader2 className="text-headplane-50 size-10 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-screen flex-col">
|
||||
<XTerm hostname={sshDetails.hostname} ipn={ipn} username={sshDetails.username} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,74 +1,102 @@
|
||||
import { Ellipsis } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import Menu from '~/components/Menu';
|
||||
import type { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import Delete from '../dialogs/delete-user';
|
||||
import Reassign from '../dialogs/reassign-user';
|
||||
import Rename from '../dialogs/rename-user';
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import Menu from "~/components/Menu";
|
||||
import type { Machine, User } from "~/types";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import Delete from "../dialogs/delete-user";
|
||||
import LinkUser from "../dialogs/link-user";
|
||||
import Reassign from "../dialogs/reassign-user";
|
||||
import Rename from "../dialogs/rename-user";
|
||||
|
||||
interface MenuProps {
|
||||
user: User & {
|
||||
headplaneRole: string;
|
||||
machines: Machine[];
|
||||
};
|
||||
user: User & {
|
||||
headplaneRole: string;
|
||||
machines: Machine[];
|
||||
};
|
||||
headscaleUsers: { id: string; name: string; claimed: boolean }[];
|
||||
currentLink?: string;
|
||||
}
|
||||
|
||||
type Modal = 'rename' | 'delete' | 'reassign' | null;
|
||||
type Modal = "rename" | "delete" | "reassign" | "link" | null;
|
||||
|
||||
export default function UserMenu({ user }: MenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
return (
|
||||
<>
|
||||
{modal === 'rename' && (
|
||||
<Rename
|
||||
isOpen={modal === 'rename'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === 'delete' && (
|
||||
<Delete
|
||||
isOpen={modal === 'delete'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === 'reassign' && (
|
||||
<Reassign
|
||||
isOpen={modal === 'reassign'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
export default function UserMenu({ user, headscaleUsers, currentLink }: MenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
|
||||
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : ['reassign']}>
|
||||
<Menu.IconButton
|
||||
className={cn(
|
||||
'py-0.5 w-10 bg-transparent border-transparent',
|
||||
'border group-hover:border-headplane-200',
|
||||
'dark:group-hover:border-headplane-700',
|
||||
)}
|
||||
label="Machine Options"
|
||||
>
|
||||
<Ellipsis className="h-5" />
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="rename">Rename user</Menu.Item>
|
||||
<Menu.Item key="reassign">Change role</Menu.Item>
|
||||
<Menu.Item key="delete" textValue="Delete">
|
||||
<p className="text-red-500 dark:text-red-400">Delete</p>
|
||||
</Menu.Item>
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
const disabledKeys: string[] = [];
|
||||
if (user.provider === "oidc") {
|
||||
disabledKeys.push("rename");
|
||||
} else {
|
||||
disabledKeys.push("reassign", "link");
|
||||
}
|
||||
|
||||
// Filter linkable users: unclaimed, or the one currently linked to this user
|
||||
const linkableUsers = headscaleUsers.filter((u) => !u.claimed || u.id === currentLink);
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal === "rename" && (
|
||||
<Rename
|
||||
isOpen={modal === "rename"}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === "delete" && (
|
||||
<Delete
|
||||
isOpen={modal === "delete"}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === "reassign" && (
|
||||
<Reassign
|
||||
isOpen={modal === "reassign"}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === "link" && (
|
||||
<LinkUser
|
||||
currentLink={currentLink}
|
||||
headscaleUsers={linkableUsers}
|
||||
isOpen={modal === "link"}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu disabledKeys={disabledKeys}>
|
||||
<Menu.IconButton
|
||||
className={cn(
|
||||
"w-10 border-transparent bg-transparent py-0.5",
|
||||
"border group-hover:border-headplane-200",
|
||||
"dark:group-hover:border-headplane-700",
|
||||
)}
|
||||
label="User Options"
|
||||
>
|
||||
<Ellipsis className="h-5" />
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="rename">Rename user</Menu.Item>
|
||||
<Menu.Item key="reassign">Change role</Menu.Item>
|
||||
<Menu.Item key="link">Link Headscale user</Menu.Item>
|
||||
<Menu.Item key="delete" textValue="Delete">
|
||||
<p className="text-red-500 dark:text-red-400">Delete</p>
|
||||
</Menu.Item>
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,97 +1,97 @@
|
||||
import { CircleUser } from 'lucide-react';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import MenuOptions from './menu';
|
||||
import { CircleUser } from "lucide-react";
|
||||
|
||||
import StatusCircle from "~/components/StatusCircle";
|
||||
import { Machine, User } from "~/types";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import MenuOptions from "./menu";
|
||||
|
||||
interface UserRowProps {
|
||||
role: string;
|
||||
user: User & { machines: Machine[] };
|
||||
role: string;
|
||||
user: User & { machines: Machine[] };
|
||||
headscaleUsers: { id: string; name: string; claimed: boolean }[];
|
||||
currentLink?: string;
|
||||
}
|
||||
|
||||
export default function UserRow({ user, role }: UserRowProps) {
|
||||
const isOnline = user.machines.some((machine) => machine.online);
|
||||
const lastSeen = user.machines.reduce(
|
||||
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
|
||||
0,
|
||||
);
|
||||
export default function UserRow({ user, role, headscaleUsers, currentLink }: UserRowProps) {
|
||||
const isOnline = user.machines.some((machine) => machine.online);
|
||||
const lastSeen = user.machines.reduce(
|
||||
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
||||
key={user.id}
|
||||
>
|
||||
<td className="pl-0.5 py-2">
|
||||
<div className="flex items-center">
|
||||
{user.profilePicUrl ? (
|
||||
<img
|
||||
alt={user.name || user.displayName}
|
||||
className="w-10 h-10 rounded-full"
|
||||
src={user.profilePicUrl}
|
||||
/>
|
||||
) : (
|
||||
<CircleUser className="w-10 h-10" />
|
||||
)}
|
||||
<div className="ml-4">
|
||||
<p className={cn('font-semibold leading-snug')}>
|
||||
{user.name || user.displayName}
|
||||
</p>
|
||||
<p className="text-sm opacity-50">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="pl-0.5 py-2">
|
||||
<p>{mapRoleToName(role)}</p>
|
||||
</td>
|
||||
<td className="pl-0.5 py-2">
|
||||
<p
|
||||
className="text-sm text-headplane-600 dark:text-headplane-300"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</td>
|
||||
<td className="pl-0.5 py-2">
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 text-sm',
|
||||
'text-headplane-600 dark:text-headplane-300',
|
||||
)}
|
||||
>
|
||||
<StatusCircle className="w-4 h-4" isOnline={isOnline} />
|
||||
<p suppressHydrationWarning>
|
||||
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions user={{ ...user, headplaneRole: role }} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
return (
|
||||
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={user.id}>
|
||||
<td className="py-2 pl-0.5">
|
||||
<div className="flex items-center">
|
||||
{user.profilePicUrl ? (
|
||||
<img
|
||||
alt={user.name || user.displayName}
|
||||
className="h-10 w-10 rounded-full"
|
||||
src={user.profilePicUrl}
|
||||
/>
|
||||
) : (
|
||||
<CircleUser className="h-10 w-10" />
|
||||
)}
|
||||
<div className="ml-4">
|
||||
<p className={cn("font-semibold leading-snug")}>{user.name || user.displayName}</p>
|
||||
<p className="text-sm opacity-50">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 pl-0.5">
|
||||
<p>{mapRoleToName(role)}</p>
|
||||
</td>
|
||||
<td className="py-2 pl-0.5">
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-sm" suppressHydrationWarning>
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</td>
|
||||
<td className="py-2 pl-0.5">
|
||||
<span
|
||||
className={cn(
|
||||
"flex items-center gap-x-1 text-sm",
|
||||
"text-headplane-600 dark:text-headplane-300",
|
||||
)}
|
||||
>
|
||||
<StatusCircle className="h-4 w-4" isOnline={isOnline} />
|
||||
<p suppressHydrationWarning>
|
||||
{isOnline ? "Connected" : new Date(lastSeen).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions
|
||||
currentLink={currentLink}
|
||||
headscaleUsers={headscaleUsers}
|
||||
user={{ ...user, headplaneRole: role }}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function mapRoleToName(role: string) {
|
||||
switch (role) {
|
||||
case 'no-oidc':
|
||||
return <p className="opacity-50">Unmanaged</p>;
|
||||
case 'invalid-oidc':
|
||||
return <p className="opacity-50">Invalid</p>;
|
||||
case 'no-role':
|
||||
return <p className="opacity-50">Unregistered</p>;
|
||||
case 'owner':
|
||||
return 'Owner';
|
||||
case 'admin':
|
||||
return 'Admin';
|
||||
case 'network_admin':
|
||||
return 'Network Admin';
|
||||
case 'it_admin':
|
||||
return 'IT Admin';
|
||||
case 'auditor':
|
||||
return 'Auditor';
|
||||
case 'member':
|
||||
return 'Member';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
switch (role) {
|
||||
case "no-oidc":
|
||||
return <p className="opacity-50">Unmanaged</p>;
|
||||
case "invalid-oidc":
|
||||
return <p className="opacity-50">Invalid</p>;
|
||||
case "no-role":
|
||||
return <p className="opacity-50">Unregistered</p>;
|
||||
case "owner":
|
||||
return "Owner";
|
||||
case "admin":
|
||||
return "Admin";
|
||||
case "network_admin":
|
||||
return "Network Admin";
|
||||
case "it_admin":
|
||||
return "IT Admin";
|
||||
case "auditor":
|
||||
return "Auditor";
|
||||
case "member":
|
||||
return <p className="opacity-50">No Access</p>;
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import Dialog from "~/components/Dialog";
|
||||
import Notice from "~/components/Notice";
|
||||
import type { User } from "~/types";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface LinkUserProps {
|
||||
user: User & { headplaneRole: string };
|
||||
headscaleUsers: { id: string; name: string }[];
|
||||
currentLink?: string;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function LinkUser({
|
||||
user,
|
||||
headscaleUsers,
|
||||
currentLink,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: LinkUserProps) {
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Link Headscale user for {user.name || user.displayName}</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
Select which Headscale user this OIDC identity should be linked to. This controls which
|
||||
machines they can manage and enables self-service features.
|
||||
</Dialog.Text>
|
||||
{headscaleUsers.length === 0 ? (
|
||||
<Notice>All Headscale users are already linked to other accounts.</Notice>
|
||||
) : (
|
||||
<>
|
||||
<input name="action_id" type="hidden" value="link_user" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<select
|
||||
className={cn(
|
||||
"w-full rounded-lg border p-2",
|
||||
"border-headplane-200 dark:border-headplane-700",
|
||||
"bg-headplane-50 dark:bg-headplane-900",
|
||||
)}
|
||||
defaultValue={currentLink ?? ""}
|
||||
name="headscale_user_id"
|
||||
required
|
||||
>
|
||||
<option value="">Select a Headscale user...</option>
|
||||
{headscaleUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
{u.id === currentLink ? " (current)" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,52 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { redirect } from 'react-router';
|
||||
import { users } from '~/server/db/schema';
|
||||
import type { Route } from './+types/onboarding-skip';
|
||||
import { eq } from "drizzle-orm";
|
||||
import { redirect } from "react-router";
|
||||
|
||||
import { users } from "~/server/db/schema";
|
||||
|
||||
import type { Route } from "./+types/onboarding-skip";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
const { user } = await context.sessions.auth(request);
|
||||
await context.db
|
||||
.update(users)
|
||||
.set({
|
||||
onboarded: true,
|
||||
})
|
||||
.where(eq(users.sub, user.subject));
|
||||
try {
|
||||
const principal = await context.auth.require(request);
|
||||
if (principal.kind !== "oidc") {
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
return redirect('/machines');
|
||||
} catch {
|
||||
return redirect('/login');
|
||||
}
|
||||
await context.db
|
||||
.update(users)
|
||||
.set({ onboarded: true })
|
||||
.where(eq(users.sub, principal.user.subject));
|
||||
|
||||
return redirect("/machines");
|
||||
} catch {
|
||||
return redirect("/login");
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request, context }: Route.ActionArgs) {
|
||||
try {
|
||||
const principal = await context.auth.require(request);
|
||||
if (principal.kind !== "oidc") {
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||
|
||||
if (headscaleUserId) {
|
||||
const linked = await context.auth.linkHeadscaleUser(principal.user.id, headscaleUserId);
|
||||
if (!linked) {
|
||||
return redirect("/onboarding");
|
||||
}
|
||||
}
|
||||
|
||||
await context.db
|
||||
.update(users)
|
||||
.set({ onboarded: true })
|
||||
.where(eq(users.sub, principal.user.subject));
|
||||
|
||||
return redirect("/machines");
|
||||
} catch {
|
||||
return redirect("/login");
|
||||
}
|
||||
}
|
||||
|
||||
+366
-307
@@ -1,328 +1,387 @@
|
||||
import { Icon } from '@iconify/react';
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { NavLink } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Link from '~/components/Link';
|
||||
import Options from '~/components/Options';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import { Machine } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import log from '~/utils/log';
|
||||
import toast from '~/utils/toast';
|
||||
import type { Route } from './+types/onboarding';
|
||||
import { Icon } from "@iconify/react";
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import { useEffect } from "react";
|
||||
import { Form, NavLink } from "react-router";
|
||||
|
||||
import Button from "~/components/Button";
|
||||
import Card from "~/components/Card";
|
||||
import Link from "~/components/Link";
|
||||
import Options from "~/components/Options";
|
||||
import StatusCircle from "~/components/StatusCircle";
|
||||
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
||||
import { Machine } from "~/types";
|
||||
import cn from "~/utils/cn";
|
||||
import { useLiveData } from "~/utils/live-data";
|
||||
import log from "~/utils/log";
|
||||
import toast from "~/utils/toast";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
import type { Route } from "./+types/onboarding";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const principal = await context.auth.require(request);
|
||||
if (principal.kind !== "oidc") {
|
||||
throw new Error("Onboarding is only available for OIDC users.");
|
||||
}
|
||||
|
||||
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
|
||||
// We need to convert this to a known value to return it to the client so we can
|
||||
// automatically tab to the correct download button.
|
||||
const userAgent = request.headers.get('user-agent');
|
||||
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
|
||||
let osValue = 'linux';
|
||||
switch (os?.[0]) {
|
||||
case 'Windows':
|
||||
osValue = 'windows';
|
||||
break;
|
||||
case 'Mac OS X':
|
||||
osValue = 'macos';
|
||||
break;
|
||||
const userAgent = request.headers.get("user-agent");
|
||||
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
|
||||
let osValue = "linux";
|
||||
switch (os?.[0]) {
|
||||
case "Windows":
|
||||
osValue = "windows";
|
||||
break;
|
||||
case "Mac OS X":
|
||||
osValue = "macos";
|
||||
break;
|
||||
|
||||
case 'iPhone':
|
||||
case 'iPad':
|
||||
osValue = 'ios';
|
||||
break;
|
||||
case "iPhone":
|
||||
case "iPad":
|
||||
osValue = "ios";
|
||||
break;
|
||||
|
||||
case 'Android':
|
||||
osValue = 'android';
|
||||
break;
|
||||
case "Android":
|
||||
osValue = "android";
|
||||
break;
|
||||
|
||||
default:
|
||||
osValue = 'linux';
|
||||
break;
|
||||
}
|
||||
default:
|
||||
osValue = "linux";
|
||||
break;
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
let firstMachine: Machine | undefined;
|
||||
try {
|
||||
const nodes = await api.getNodes();
|
||||
const node = nodes.find((n) => {
|
||||
if (n.user.provider !== 'oidc') {
|
||||
return false;
|
||||
}
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = n.user.providerId?.split('/').pop();
|
||||
if (!subject) {
|
||||
return false;
|
||||
}
|
||||
const hsUserId = principal.user.headscaleUserId;
|
||||
let firstMachine: Machine | undefined;
|
||||
let needsUserLink = false;
|
||||
let linkedUserName: string | undefined;
|
||||
let headscaleUsers: { id: string; name: string }[] = [];
|
||||
|
||||
if (subject !== session.user.subject) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||
|
||||
return true;
|
||||
});
|
||||
if (hsUserId) {
|
||||
const hsUser = apiUsers.find((u) => u.id === hsUserId);
|
||||
linkedUserName = hsUser ? getUserDisplayName(hsUser) : undefined;
|
||||
firstMachine = nodes.find((n) => n.user?.id === hsUserId);
|
||||
} else {
|
||||
const matched = findHeadscaleUserBySubject(
|
||||
apiUsers,
|
||||
principal.user.subject,
|
||||
principal.profile.email,
|
||||
);
|
||||
|
||||
firstMachine = node;
|
||||
} catch (e) {
|
||||
// If we cannot lookup nodes, we cannot proceed
|
||||
log.debug('api', 'Failed to lookup nodes %o', e);
|
||||
}
|
||||
if (matched) {
|
||||
await context.auth.linkHeadscaleUser(principal.user.id, matched.id);
|
||||
linkedUserName = getUserDisplayName(matched);
|
||||
firstMachine = nodes.find((n) => n.user?.id === matched.id);
|
||||
} else {
|
||||
needsUserLink = true;
|
||||
const claimed = await context.auth.claimedHeadscaleUserIds();
|
||||
headscaleUsers = apiUsers
|
||||
.filter((u) => !claimed.has(u.id))
|
||||
.map((u) => ({
|
||||
id: u.id,
|
||||
name: getUserDisplayName(u),
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.debug("api", "Failed to lookup nodes %o", e);
|
||||
}
|
||||
|
||||
return {
|
||||
user: session.user,
|
||||
osValue,
|
||||
firstMachine,
|
||||
};
|
||||
return {
|
||||
user: {
|
||||
subject: principal.user.subject,
|
||||
name: principal.profile.name,
|
||||
email: principal.profile.email,
|
||||
username: principal.profile.username,
|
||||
picture: principal.profile.picture,
|
||||
},
|
||||
osValue,
|
||||
firstMachine,
|
||||
needsUserLink,
|
||||
linkedUserName,
|
||||
headscaleUsers,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page({
|
||||
loaderData: { user, osValue, firstMachine },
|
||||
loaderData: { user, osValue, firstMachine, needsUserLink, linkedUserName, headscaleUsers },
|
||||
}: Route.ComponentProps) {
|
||||
const { pause, resume } = useLiveData();
|
||||
useEffect(() => {
|
||||
if (firstMachine) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}, [firstMachine]);
|
||||
const { pause, resume } = useLiveData();
|
||||
useEffect(() => {
|
||||
if (firstMachine) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}, [firstMachine]);
|
||||
|
||||
const subject = user.email ? (
|
||||
<>
|
||||
as <strong>{user.email}</strong>
|
||||
</>
|
||||
) : (
|
||||
'with your OIDC provider'
|
||||
);
|
||||
const subject = user.email ? (
|
||||
<>
|
||||
as <strong>{user.email}</strong>
|
||||
</>
|
||||
) : (
|
||||
"with your OIDC provider"
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed w-full h-screen flex items-center px-4">
|
||||
<div className="w-fit mx-auto grid grid-cols-1 md:grid-cols-2 gap-4 mb-24">
|
||||
<Card className="max-w-lg" variant="flat">
|
||||
<Card.Title className="mb-8">
|
||||
Welcome!
|
||||
<br />
|
||||
Let's get set up
|
||||
</Card.Title>
|
||||
<Card.Text>
|
||||
Install Tailscale and sign in {subject}. Once you sign in on a
|
||||
device, it will be automatically added to your Headscale network.
|
||||
</Card.Text>
|
||||
return (
|
||||
<div className="fixed flex h-screen w-full items-center px-4">
|
||||
<div className="mx-auto mb-24 grid w-fit grid-cols-1 gap-4 md:grid-cols-2">
|
||||
{needsUserLink ? (
|
||||
<Card className="col-span-2 mx-auto max-w-lg" variant="flat">
|
||||
<Card.Title className="mb-4">Link your Headscale account</Card.Title>
|
||||
<Card.Text className="mb-4">
|
||||
Headplane couldn't automatically match your SSO identity to a Headscale user.
|
||||
{headscaleUsers.length > 0
|
||||
? " Select which Headscale user you are, or skip to continue without linking."
|
||||
: " All Headscale users are already linked. You can skip this step and ask an admin to link your account later."}
|
||||
</Card.Text>
|
||||
{headscaleUsers.length > 0 ? (
|
||||
<Form method="POST" action="/onboarding/skip">
|
||||
<select
|
||||
className={cn(
|
||||
"mb-4 w-full rounded-lg border p-2",
|
||||
"border-headplane-200 dark:border-headplane-700",
|
||||
"bg-headplane-50 dark:bg-headplane-900",
|
||||
)}
|
||||
name="headscale_user_id"
|
||||
required
|
||||
>
|
||||
<option value="">Select a user...</option>
|
||||
{headscaleUsers.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button className="w-full" type="submit" variant="heavy">
|
||||
Link and Continue
|
||||
</Button>
|
||||
</Form>
|
||||
) : undefined}
|
||||
<NavLink className="mt-3 block text-center" to="/onboarding/skip">
|
||||
<Button className="w-full" variant="light">
|
||||
Skip — I'll do this later
|
||||
</Button>
|
||||
</NavLink>
|
||||
<p className="text-headplane-500 mt-2 text-center text-xs">
|
||||
Without linking, you won't be able to see your own machines or generate pre-auth keys.
|
||||
An admin can link your account later from the Users page.
|
||||
</p>
|
||||
</Card>
|
||||
) : undefined}
|
||||
{linkedUserName && !needsUserLink ? (
|
||||
<Card className="col-span-2 mx-auto max-w-lg" variant="flat">
|
||||
<p className="text-sm">
|
||||
✓ Your account has been linked to Headscale user <strong>{linkedUserName}</strong>.
|
||||
</p>
|
||||
</Card>
|
||||
) : undefined}
|
||||
<Card className="max-w-lg" variant="flat">
|
||||
<Card.Title className="mb-8">
|
||||
Welcome!
|
||||
<br />
|
||||
Let's get set up
|
||||
</Card.Title>
|
||||
<Card.Text>
|
||||
Install Tailscale and sign in {subject}. Once you sign in on a device, it will be
|
||||
automatically added to your Headscale network.
|
||||
</Card.Text>
|
||||
|
||||
<Options
|
||||
className="my-4"
|
||||
defaultSelectedKey={osValue}
|
||||
label="Download Selector"
|
||||
>
|
||||
<Options.Item
|
||||
key="linux"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="ion:terminal" />
|
||||
<span>Linux</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="flex text-md font-mono"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
'curl -fsSL https://tailscale.com/install.sh | sh',
|
||||
);
|
||||
<Options className="my-4" defaultSelectedKey={osValue} label="Download Selector">
|
||||
<Options.Item
|
||||
key="linux"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="ion:terminal" />
|
||||
<span>Linux</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="text-md flex font-mono"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
"curl -fsSL https://tailscale.com/install.sh | sh",
|
||||
);
|
||||
|
||||
toast('Copied to clipboard');
|
||||
}}
|
||||
>
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
</Button>
|
||||
<p className="text-xs mt-1 text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Click this button to copy the command.{' '}
|
||||
<Link
|
||||
name="Linux installation script"
|
||||
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
|
||||
>
|
||||
View script source
|
||||
</Link>
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="windows"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
|
||||
<span>Windows</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for Windows"
|
||||
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Windows
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Requires Windows 10 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="macos"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon
|
||||
className="ml-1 w-4"
|
||||
icon="streamline-logos:mac-finder-logo-solid"
|
||||
/>
|
||||
<span>macOS</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for macOS"
|
||||
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for macOS
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Requires macOS Big Sur 11.0 or later.
|
||||
<br />
|
||||
You can also download Tailscale on the{' '}
|
||||
<Link
|
||||
name="macOS App Store"
|
||||
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
|
||||
>
|
||||
macOS App Store
|
||||
</Link>
|
||||
{'.'}
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="ios"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
|
||||
<span>iOS</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for iOS"
|
||||
href="https://apps.apple.com/us/app/tailscale/id1470499037"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for iOS
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Requires iOS 15 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="android"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="material-symbols:android" />
|
||||
<span>Android</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for Android"
|
||||
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Android
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Requires Android 8 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
</Options>
|
||||
</Card>
|
||||
<Card variant="flat">
|
||||
{firstMachine ? (
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<Card.Title className="mb-8">
|
||||
Success!
|
||||
<br />
|
||||
We found your first device
|
||||
</Card.Title>
|
||||
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<StatusCircle
|
||||
className="size-6 mt-3"
|
||||
isOnline={firstMachine.online}
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold leading-snug">
|
||||
{firstMachine.givenName}
|
||||
</p>
|
||||
<p className="text-sm font-mono opacity-50">
|
||||
{firstMachine.name}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<p className="text-sm font-semibold">IP Addresses</p>
|
||||
{firstMachine.ipAddresses.map((ip) => (
|
||||
<p className="text-xs font-mono opacity-50" key={ip}>
|
||||
{ip}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NavLink to="/onboarding/skip">
|
||||
<Button className="w-full" variant="heavy">
|
||||
Continue
|
||||
</Button>
|
||||
</NavLink>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 h-full">
|
||||
<span className="relative flex size-4">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex h-full w-full',
|
||||
'rounded-full opacity-75 animate-ping',
|
||||
'bg-headplane-500',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex size-4 rounded-full',
|
||||
'bg-headplane-400',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<p className="font-lg">Waiting for your first device...</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<NavLink className="col-span-2 w-max mx-auto" to="/onboarding/skip">
|
||||
<Button className="flex items-center gap-1">
|
||||
I already know what I'm doing
|
||||
<ArrowRight className="p-1" />
|
||||
</Button>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
toast("Copied to clipboard");
|
||||
}}
|
||||
>
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
</Button>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 mt-1 text-center text-xs">
|
||||
Click this button to copy the command.{" "}
|
||||
<Link
|
||||
name="Linux installation script"
|
||||
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
|
||||
>
|
||||
View script source
|
||||
</Link>
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="windows"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
|
||||
<span>Windows</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for Windows"
|
||||
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Windows
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires Windows 10 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="macos"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="streamline-logos:mac-finder-logo-solid" />
|
||||
<span>macOS</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for macOS"
|
||||
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for macOS
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires macOS Big Sur 11.0 or later.
|
||||
<br />
|
||||
You can also download Tailscale on the{" "}
|
||||
<Link
|
||||
name="macOS App Store"
|
||||
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
|
||||
>
|
||||
macOS App Store
|
||||
</Link>
|
||||
{"."}
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="ios"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
|
||||
<span>iOS</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for iOS"
|
||||
href="https://apps.apple.com/us/app/tailscale/id1470499037"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for iOS
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires iOS 15 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="android"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<Icon className="ml-1 w-4" icon="material-symbols:android" />
|
||||
<span>Android</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
aria-label="Download for Android"
|
||||
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<Button className="my-4 w-full" variant="heavy">
|
||||
Download for Android
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-headplane-600 dark:text-headplane-300 text-center text-sm">
|
||||
Requires Android 8 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
</Options>
|
||||
</Card>
|
||||
<Card variant="flat">
|
||||
{firstMachine ? (
|
||||
<div className="flex h-full flex-col justify-between">
|
||||
<Card.Title className="mb-8">
|
||||
Success!
|
||||
<br />
|
||||
We found your first device
|
||||
</Card.Title>
|
||||
<div className="border-headplane-100 dark:border-headplane-800 rounded-xl border p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<StatusCircle className="mt-3 size-6" isOnline={firstMachine.online} />
|
||||
<div>
|
||||
<p className="leading-snug font-semibold">{firstMachine.givenName}</p>
|
||||
<p className="font-mono text-sm opacity-50">{firstMachine.name}</p>
|
||||
<div className="mt-6">
|
||||
<p className="text-sm font-semibold">IP Addresses</p>
|
||||
{firstMachine.ipAddresses.map((ip) => (
|
||||
<p className="font-mono text-xs opacity-50" key={ip}>
|
||||
{ip}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NavLink to="/onboarding/skip">
|
||||
<Button className="w-full" variant="heavy">
|
||||
Continue
|
||||
</Button>
|
||||
</NavLink>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-4">
|
||||
<span className="relative flex size-4">
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inline-flex h-full w-full",
|
||||
"rounded-full opacity-75 animate-ping",
|
||||
"bg-headplane-500",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn("relative inline-flex size-4 rounded-full", "bg-headplane-400")}
|
||||
/>
|
||||
</span>
|
||||
<p className="font-lg">Waiting for your first device...</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<NavLink className="col-span-2 mx-auto w-max" to="/onboarding/skip">
|
||||
<Button className="flex items-center gap-1">
|
||||
I already know what I'm doing
|
||||
<ArrowRight className="p-1" />
|
||||
</Button>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+145
-110
@@ -1,132 +1,167 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import type { Route } from './+types/overview';
|
||||
import ManageBanner from './components/manage-banner';
|
||||
import UserRow from './components/user-row';
|
||||
import { userAction } from './user-actions';
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import { eq } from "drizzle-orm";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { users as usersTable } from "~/server/db/schema";
|
||||
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import type { Machine, User } from "~/types";
|
||||
import cn from "~/utils/cn";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
import ManageBanner from "./components/manage-banner";
|
||||
import UserRow from "./components/user-row";
|
||||
import { userAction } from "./user-actions";
|
||||
|
||||
interface UserMachine extends User {
|
||||
machines: Machine[];
|
||||
machines: Machine[];
|
||||
}
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(request, Capabilities.read_users);
|
||||
if (!check) {
|
||||
// Not authorized to view this page
|
||||
throw new Error(
|
||||
'You do not have permission to view this page. Please contact your administrator.',
|
||||
);
|
||||
}
|
||||
const principal = await context.auth.require(request);
|
||||
const check = await context.auth.can(principal, Capabilities.read_users);
|
||||
if (!check) {
|
||||
// Not authorized to view this page
|
||||
throw new Error(
|
||||
"You do not have permission to view this page. Please contact your administrator.",
|
||||
);
|
||||
}
|
||||
|
||||
const writablePermission = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_users,
|
||||
);
|
||||
const writablePermission = await context.auth.can(principal, Capabilities.write_users);
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||
|
||||
const users = apiUsers.map((user) => ({
|
||||
...user,
|
||||
machines: nodes.filter((node) => node.user.id === user.id),
|
||||
}));
|
||||
const users = apiUsers.map((user) => ({
|
||||
...user,
|
||||
machines: nodes.filter((node) => node.user?.id === user.id),
|
||||
profilePicUrl:
|
||||
context.config.oidc?.profile_picture_source === "gravatar"
|
||||
? (() => {
|
||||
if (!user.email) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const roles = await Promise.all(
|
||||
users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(async (user) => {
|
||||
if (user.provider !== 'oidc') {
|
||||
return 'no-oidc';
|
||||
}
|
||||
const emailHash = user.email.trim().toLowerCase();
|
||||
const hash = createHash("sha256").update(emailHash).digest("hex");
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
|
||||
})()
|
||||
: user.profilePicUrl,
|
||||
}));
|
||||
|
||||
if (user.provider === 'oidc' && user.providerId) {
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = user.providerId.split('/').pop();
|
||||
if (!subject) {
|
||||
return 'invalid-oidc';
|
||||
}
|
||||
const roles = await Promise.all(
|
||||
users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map(async (user) => {
|
||||
if (user.provider !== "oidc") {
|
||||
return "no-oidc";
|
||||
}
|
||||
|
||||
const role = await context.sessions.roleForSubject(subject);
|
||||
return role ?? 'no-role';
|
||||
}
|
||||
const subject = getOidcSubject(user);
|
||||
if (!subject) {
|
||||
return "invalid-oidc";
|
||||
}
|
||||
|
||||
// No role means the user is not registered in Headplane, but they
|
||||
// are in Headscale. We also need to handle what happens if someone
|
||||
// logs into the UI and they don't have a Headscale setup.
|
||||
return 'no-role';
|
||||
}),
|
||||
);
|
||||
const role = await context.auth.roleForSubject(subject);
|
||||
return role ?? "no-role";
|
||||
}),
|
||||
);
|
||||
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
writable: writablePermission, // whether the user can write to the API
|
||||
oidc: context.config.oidc
|
||||
? {
|
||||
issuer: context.config.oidc.issuer,
|
||||
}
|
||||
: undefined,
|
||||
roles,
|
||||
magic,
|
||||
users,
|
||||
};
|
||||
// Build linkable Headscale users for admin link dialog
|
||||
const claimed = await context.auth.claimedHeadscaleUserIds();
|
||||
const headscaleUsers = apiUsers.map((u) => ({
|
||||
id: u.id,
|
||||
name: getUserDisplayName(u),
|
||||
claimed: claimed.has(u.id),
|
||||
}));
|
||||
|
||||
// Build a map of Headscale user -> linked Headplane subject
|
||||
const userLinks: Record<string, string | undefined> = {};
|
||||
for (const u of apiUsers) {
|
||||
const subject = getOidcSubject(u);
|
||||
if (subject) {
|
||||
const [hp] = await context.db
|
||||
.select({ hsId: usersTable.headscale_user_id })
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.sub, subject))
|
||||
.limit(1);
|
||||
userLinks[u.id] = hp?.hsId ?? undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
writable: writablePermission, // whether the user can write to the API
|
||||
oidc: context.config.oidc
|
||||
? {
|
||||
issuer: context.config.oidc.issuer,
|
||||
}
|
||||
: undefined,
|
||||
roles,
|
||||
magic,
|
||||
users,
|
||||
headscaleUsers,
|
||||
userLinks,
|
||||
};
|
||||
}
|
||||
|
||||
export const action = userAction;
|
||||
|
||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
const [users, setUsers] = useState<UserMachine[]>(loaderData.users);
|
||||
const [users, setUsers] = useState<UserMachine[]>(loaderData.users);
|
||||
|
||||
// This useEffect is entirely for the purpose of updating the users when the
|
||||
// drag and drop changes the machines between users. It's pretty hacky, but
|
||||
// the idea is to treat data.users as the source of truth and update the
|
||||
// local state when it changes.
|
||||
useEffect(() => {
|
||||
setUsers(loaderData.users);
|
||||
}, [loaderData.users]);
|
||||
// This useEffect is entirely for the purpose of updating the users when the
|
||||
// drag and drop changes the machines between users. It's pretty hacky, but
|
||||
// the idea is to treat data.users as the source of truth and update the
|
||||
// local state when it changes.
|
||||
useEffect(() => {
|
||||
setUsers(loaderData.users);
|
||||
}, [loaderData.users]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="text-2xl font-medium mb-1.5">Users</h1>
|
||||
<p className="mb-8 text-md">
|
||||
Manage the users in your network and their permissions.
|
||||
</p>
|
||||
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="text-left px-0.5">
|
||||
<th className="uppercase text-xs font-bold pb-2">User</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Role</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Created At</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
|
||||
'border-t border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
role={loaderData.roles[users.indexOf(user)]}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
<h1 className="mb-1.5 text-2xl font-medium">Users</h1>
|
||||
<p className="text-md mb-8">Manage the users in your network and their permissions.</p>
|
||||
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[640px] table-auto rounded-lg">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="px-0.5 text-left">
|
||||
<th className="pb-2 text-xs font-bold uppercase">User</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Role</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Created At</th>
|
||||
<th className="pb-2 text-xs font-bold uppercase">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
"divide-y divide-headplane-100 dark:divide-headplane-800 align-top",
|
||||
"border-t border-headplane-100 dark:border-headplane-800",
|
||||
)}
|
||||
>
|
||||
{users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
currentLink={loaderData.userLinks[user.id]}
|
||||
headscaleUsers={loaderData.headscaleUsers}
|
||||
role={loaderData.roles[users.indexOf(user)]}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,115 +1,139 @@
|
||||
import { data } from 'react-router';
|
||||
import { Capabilities, Roles } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
import { data } from "react-router";
|
||||
|
||||
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import type { Role } from "~/server/web/roles";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(request, Capabilities.write_users);
|
||||
if (!check) {
|
||||
throw data('You do not have permission to update users', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
const principal = await context.auth.require(request);
|
||||
const check = await context.auth.can(principal, Capabilities.write_users);
|
||||
if (!check) {
|
||||
throw data("You do not have permission to update users", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('Missing `action_id` in the form data.', {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
const formData = await request.formData();
|
||||
const action = formData.get("action_id")?.toString();
|
||||
if (!action) {
|
||||
throw data("Missing `action_id` in the form data.", {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
switch (action) {
|
||||
case 'create_user': {
|
||||
const name = formData.get('username')?.toString();
|
||||
const displayName = formData.get('display_name')?.toString();
|
||||
const email = formData.get('email')?.toString();
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
switch (action) {
|
||||
case "create_user": {
|
||||
const name = formData.get("username")?.toString();
|
||||
const displayName = formData.get("display_name")?.toString();
|
||||
const email = formData.get("email")?.toString();
|
||||
|
||||
if (!name) {
|
||||
throw data('Missing `username` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
if (!name) {
|
||||
throw data("Missing `username` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.createUser(name, email, displayName);
|
||||
return { message: 'User created successfully' };
|
||||
}
|
||||
case 'delete_user': {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
if (!userId) {
|
||||
throw data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
await api.createUser(name, email, displayName);
|
||||
return { message: "User created successfully" };
|
||||
}
|
||||
case "delete_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
if (!userId) {
|
||||
throw data("Missing `user_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.deleteUser(userId);
|
||||
return { message: 'User deleted successfully' };
|
||||
}
|
||||
case 'rename_user': {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
const newName = formData.get('new_name')?.toString();
|
||||
if (!userId || !newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
await api.deleteUser(userId);
|
||||
return { message: "User deleted successfully" };
|
||||
}
|
||||
case "rename_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
const newName = formData.get("new_name")?.toString();
|
||||
if (!userId || !newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data(`No user found with id: ${userId}`, { status: 400 });
|
||||
}
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data(`No user found with id: ${userId}`, { status: 400 });
|
||||
}
|
||||
|
||||
if (user.provider === 'oidc') {
|
||||
// OIDC users cannot be renamed via this endpoint, return an error
|
||||
throw data('Users managed by OIDC cannot be renamed', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
if (user.provider === "oidc") {
|
||||
// OIDC users cannot be renamed via this endpoint, return an error
|
||||
throw data("Users managed by OIDC cannot be renamed", {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
await api.renameUser(userId, newName);
|
||||
return { message: 'User renamed successfully' };
|
||||
}
|
||||
case 'reassign_user': {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
const newRole = formData.get('new_role')?.toString();
|
||||
if (!userId || !newRole) {
|
||||
throw data('Missing `user_id` or `new_role` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
await api.renameUser(userId, newName);
|
||||
return { message: "User renamed successfully" };
|
||||
}
|
||||
case "reassign_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
const newRole = formData.get("new_role")?.toString();
|
||||
if (!userId || !newRole) {
|
||||
throw data("Missing `user_id` or `new_role` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user?.providerId) {
|
||||
throw data('Specified user is not an OIDC user', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data("Specified user not found", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = user.providerId?.split('/').pop();
|
||||
if (!subject) {
|
||||
throw data(
|
||||
'Malformed `providerId` for the specified user. Cannot find subject.',
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const subject = getOidcSubject(user);
|
||||
if (!subject) {
|
||||
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
|
||||
}
|
||||
|
||||
const result = await context.sessions.reassignSubject(
|
||||
subject,
|
||||
newRole as keyof typeof Roles,
|
||||
);
|
||||
const result = await context.auth.reassignSubject(subject, newRole as Role);
|
||||
|
||||
if (!result) {
|
||||
throw data('Failed to reassign user role.', { status: 500 });
|
||||
}
|
||||
if (!result) {
|
||||
throw data("Failed to reassign user role.", { status: 500 });
|
||||
}
|
||||
|
||||
return { message: 'User reassigned successfully' };
|
||||
}
|
||||
default:
|
||||
throw data('Invalid `action_id` provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
return { message: "User reassigned successfully" };
|
||||
}
|
||||
case "link_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||
if (!userId || !headscaleUserId) {
|
||||
throw data("Missing `user_id` or `headscale_user_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data("Specified user not found", { status: 400 });
|
||||
}
|
||||
|
||||
const subject = getOidcSubject(user);
|
||||
if (!subject) {
|
||||
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
|
||||
}
|
||||
|
||||
const linked = await context.auth.linkHeadscaleUserBySubject(subject, headscaleUserId);
|
||||
if (!linked) {
|
||||
throw data("That Headscale user is already linked to another account.", { status: 409 });
|
||||
}
|
||||
|
||||
return { message: "Headscale user linked successfully" };
|
||||
}
|
||||
default:
|
||||
throw data("Invalid `action_id` provided.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
+138
-149
@@ -1,206 +1,195 @@
|
||||
import { type } from 'arktype';
|
||||
import log from '~/utils/log';
|
||||
import DockerIntegration from './integration/docker';
|
||||
import KubernetesIntegration from './integration/kubernetes';
|
||||
import ProcIntegration from './integration/proc';
|
||||
import { deprecatedField } from './utils';
|
||||
import { type } from "arktype";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import DockerIntegration from "./integration/docker";
|
||||
import KubernetesIntegration from "./integration/kubernetes";
|
||||
import ProcIntegration from "./integration/proc";
|
||||
import { deprecatedField } from "./utils";
|
||||
|
||||
export const pathSupportedKeys = [
|
||||
'server.cookie_secret',
|
||||
'oidc.client_secret',
|
||||
'oidc.headscale_api_key',
|
||||
'integration.agent.pre_authkey',
|
||||
"server.cookie_secret",
|
||||
"oidc.client_secret",
|
||||
"oidc.headscale_api_key",
|
||||
"integration.agent.pre_authkey",
|
||||
] as const;
|
||||
|
||||
const serverConfig = type({
|
||||
host: 'string.ip = "0.0.0.0"',
|
||||
port: 'number.integer = 3000',
|
||||
base_url: 'string.url?',
|
||||
data_path: 'string.lower = "/var/lib/headplane/"',
|
||||
host: 'string.ip = "0.0.0.0"',
|
||||
port: "number.integer = 3000",
|
||||
base_url: "string.url?",
|
||||
data_path: 'string.lower = "/var/lib/headplane/"',
|
||||
info_secret: "string?",
|
||||
|
||||
cookie_secret: '(32 <= string <= 32)',
|
||||
cookie_secure: 'boolean = true',
|
||||
cookie_domain: 'string.lower?',
|
||||
cookie_max_age: 'number.integer = 86400',
|
||||
cookie_secret: "(32 <= string <= 32)",
|
||||
cookie_secure: "boolean = true",
|
||||
cookie_domain: "string.lower?",
|
||||
cookie_max_age: "number.integer = 86400",
|
||||
});
|
||||
|
||||
const partialServerConfig = type({
|
||||
host: 'string.ip?',
|
||||
port: 'number.integer?',
|
||||
base_url: 'string.url?',
|
||||
data_path: 'string.lower?',
|
||||
host: "string.ip?",
|
||||
port: "number.integer?",
|
||||
base_url: "string.url?",
|
||||
data_path: "string.lower?",
|
||||
info_secret: "string?",
|
||||
|
||||
cookie_secret: '(32 <= string <= 32)?',
|
||||
cookie_secure: 'boolean?',
|
||||
cookie_domain: 'string.lower?',
|
||||
cookie_max_age: 'number.integer?',
|
||||
cookie_secret: "(32 <= string <= 32)?",
|
||||
cookie_secure: "boolean?",
|
||||
cookie_domain: "string.lower?",
|
||||
cookie_max_age: "number.integer?",
|
||||
});
|
||||
|
||||
const headscaleConfig = type({
|
||||
url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)),
|
||||
public_url: type('string.url')
|
||||
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
|
||||
.optional(),
|
||||
config_path: 'string.lower?',
|
||||
config_strict: 'boolean = true',
|
||||
dns_records_path: 'string.lower?',
|
||||
tls_cert_path: 'string.lower?',
|
||||
url: type("string.url").pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v)),
|
||||
public_url: type("string.url")
|
||||
.pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v))
|
||||
.optional(),
|
||||
config_path: "string.lower?",
|
||||
config_strict: "boolean = true",
|
||||
dns_records_path: "string.lower?",
|
||||
tls_cert_path: "string.lower?",
|
||||
});
|
||||
|
||||
const partialHeadscaleConfig = type({
|
||||
url: type('string.url')
|
||||
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
|
||||
.optional(),
|
||||
public_url: type('string.url')
|
||||
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
|
||||
.optional(),
|
||||
config_path: 'string.lower?',
|
||||
config_strict: 'boolean?',
|
||||
dns_records_path: 'string.lower?',
|
||||
tls_cert_path: 'string.lower?',
|
||||
url: type("string.url")
|
||||
.pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v))
|
||||
.optional(),
|
||||
public_url: type("string.url")
|
||||
.pipe((v) => (v.endsWith("/") ? v.slice(0, -1) : v))
|
||||
.optional(),
|
||||
config_path: "string.lower?",
|
||||
config_strict: "boolean?",
|
||||
dns_records_path: "string.lower?",
|
||||
tls_cert_path: "string.lower?",
|
||||
});
|
||||
|
||||
const oidcConfig = type({
|
||||
issuer: 'string.url',
|
||||
client_id: 'string',
|
||||
client_secret: 'string',
|
||||
headscale_api_key: 'string',
|
||||
use_pkce: 'boolean = false',
|
||||
redirect_uri: type('string.url')
|
||||
.pipe((value, ctx) => {
|
||||
log.warn(
|
||||
'config',
|
||||
'%s is deprecated and will be removed in 0.7.0',
|
||||
ctx.propString,
|
||||
);
|
||||
enabled: "boolean = true",
|
||||
issuer: "string.url",
|
||||
client_id: "string",
|
||||
client_secret: "string",
|
||||
headscale_api_key: "string",
|
||||
use_pkce: "boolean = false",
|
||||
redirect_uri: type("string.url")
|
||||
.pipe((value, ctx) => {
|
||||
log.warn("config", "%s is deprecated and will be removed in 0.7.0", ctx.propString);
|
||||
|
||||
const cleanedValue = new URL(value.trim());
|
||||
if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) {
|
||||
cleanedValue.pathname = cleanedValue.pathname.replace(
|
||||
`${__PREFIX__}/oidc/callback`,
|
||||
'/',
|
||||
);
|
||||
const cleanedValue = new URL(value.trim());
|
||||
if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) {
|
||||
cleanedValue.pathname = cleanedValue.pathname.replace(`${__PREFIX__}/oidc/callback`, "/");
|
||||
|
||||
log.warn(
|
||||
'config',
|
||||
'Please migrate to using `server.base_url` with a value of "%s"',
|
||||
cleanedValue.toString(),
|
||||
);
|
||||
}
|
||||
log.warn(
|
||||
"config",
|
||||
'Please migrate to using `server.base_url` with a value of "%s"',
|
||||
cleanedValue.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
return cleanedValue.toString();
|
||||
})
|
||||
.optional(),
|
||||
disable_api_key_login: 'boolean = false',
|
||||
scope: 'string = "openid email profile"',
|
||||
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
||||
extra_params: 'Record<string, string>?',
|
||||
return cleanedValue.toString();
|
||||
})
|
||||
.optional(),
|
||||
disable_api_key_login: "boolean = false",
|
||||
scope: 'string = "openid email profile"',
|
||||
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
||||
extra_params: "Record<string, string>?",
|
||||
|
||||
authorization_endpoint: 'string.url?',
|
||||
token_endpoint: 'string.url?',
|
||||
userinfo_endpoint: 'string.url?',
|
||||
authorization_endpoint: "string.url?",
|
||||
token_endpoint: "string.url?",
|
||||
userinfo_endpoint: "string.url?",
|
||||
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
||||
|
||||
// Old/deprecated options
|
||||
user_storage_file: 'string.lower = "/var/lib/headplane/users.json"',
|
||||
strict_validation: type('unknown').narrow(deprecatedField()).optional(),
|
||||
token_endpoint_auth_method: type('unknown')
|
||||
.narrow(deprecatedField())
|
||||
.optional(),
|
||||
// Old/deprecated options
|
||||
strict_validation: type("unknown").narrow(deprecatedField()).optional(),
|
||||
});
|
||||
|
||||
const partialOidcConfig = type({
|
||||
issuer: 'string.url?',
|
||||
client_id: 'string?',
|
||||
client_secret: 'string?',
|
||||
use_pkce: 'boolean?',
|
||||
headscale_api_key: 'string?',
|
||||
redirect_uri: 'string.url?',
|
||||
disable_api_key_login: 'boolean?',
|
||||
scope: 'string?',
|
||||
extra_params: 'Record<string, string>?',
|
||||
profile_picture_source: '"oidc" | "gravatar"?',
|
||||
enabled: "boolean?",
|
||||
issuer: "string.url?",
|
||||
client_id: "string?",
|
||||
client_secret: "string?",
|
||||
use_pkce: "boolean?",
|
||||
headscale_api_key: "string?",
|
||||
redirect_uri: "string.url?",
|
||||
disable_api_key_login: "boolean?",
|
||||
scope: "string?",
|
||||
extra_params: "Record<string, string>?",
|
||||
profile_picture_source: '"oidc" | "gravatar"?',
|
||||
|
||||
authorization_endpoint: 'string.url?',
|
||||
token_endpoint: 'string.url?',
|
||||
userinfo_endpoint: 'string.url?',
|
||||
authorization_endpoint: "string.url?",
|
||||
token_endpoint: "string.url?",
|
||||
userinfo_endpoint: "string.url?",
|
||||
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
||||
|
||||
// Old/deprecated options
|
||||
user_storage_file: 'string.lower?',
|
||||
strict_validation: type('unknown').narrow(deprecatedField()).optional(),
|
||||
token_endpoint_auth_method: type('unknown')
|
||||
.narrow(deprecatedField())
|
||||
.optional(),
|
||||
// Old/deprecated options
|
||||
strict_validation: type("unknown").narrow(deprecatedField()).optional(),
|
||||
});
|
||||
|
||||
const agentConfig = type({
|
||||
enabled: 'boolean',
|
||||
host_name: 'string = "headplane-agent"',
|
||||
pre_authkey: 'string',
|
||||
cache_ttl: 'number.integer = 180000',
|
||||
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
|
||||
executable_path: 'string = "/usr/libexec/headplane/agent"',
|
||||
work_dir: 'string = "/var/lib/headplane/agent"',
|
||||
enabled: "boolean",
|
||||
host_name: 'string = "headplane-agent"',
|
||||
pre_authkey: "string",
|
||||
cache_ttl: "number.integer = 180000",
|
||||
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
|
||||
executable_path: 'string = "/usr/libexec/headplane/agent"',
|
||||
work_dir: 'string = "/var/lib/headplane/agent"',
|
||||
});
|
||||
|
||||
const partialAgentConfig = type({
|
||||
enabled: 'boolean?',
|
||||
host_name: 'string?',
|
||||
pre_authkey: 'string?',
|
||||
cache_ttl: 'number.integer?',
|
||||
cache_path: 'string?',
|
||||
executable_path: 'string?',
|
||||
work_dir: 'string?',
|
||||
enabled: "boolean?",
|
||||
host_name: "string?",
|
||||
pre_authkey: "string?",
|
||||
cache_ttl: "number.integer?",
|
||||
cache_path: "string?",
|
||||
executable_path: "string?",
|
||||
work_dir: "string?",
|
||||
});
|
||||
|
||||
const integrationConfig = type({
|
||||
docker: DockerIntegration.configSchema.full,
|
||||
kubernetes: KubernetesIntegration.configSchema.full,
|
||||
proc: ProcIntegration.configSchema.full,
|
||||
agent: agentConfig.optional(),
|
||||
docker: DockerIntegration.configSchema.full,
|
||||
kubernetes: KubernetesIntegration.configSchema.full,
|
||||
proc: ProcIntegration.configSchema.full,
|
||||
agent: agentConfig.optional(),
|
||||
}).partial();
|
||||
|
||||
export const partialIntegrationConfig = type({
|
||||
docker: DockerIntegration.configSchema.partial,
|
||||
kubernetes: KubernetesIntegration.configSchema.partial,
|
||||
proc: ProcIntegration.configSchema.partial,
|
||||
agent: partialAgentConfig.optional(),
|
||||
docker: DockerIntegration.configSchema.partial,
|
||||
kubernetes: KubernetesIntegration.configSchema.partial,
|
||||
proc: ProcIntegration.configSchema.partial,
|
||||
agent: partialAgentConfig.optional(),
|
||||
}).partial();
|
||||
|
||||
export const headplaneConfig = type({
|
||||
debug: 'boolean = false',
|
||||
server: serverConfig,
|
||||
headscale: headscaleConfig,
|
||||
oidc: oidcConfig.optional(),
|
||||
integration: integrationConfig.optional(),
|
||||
}).onDeepUndeclaredKey('delete');
|
||||
debug: "boolean = false",
|
||||
server: serverConfig,
|
||||
headscale: headscaleConfig,
|
||||
oidc: oidcConfig.optional(),
|
||||
integration: integrationConfig.optional(),
|
||||
}).onDeepUndeclaredKey("delete");
|
||||
|
||||
export const partialHeadplaneConfig = type({
|
||||
debug: 'boolean?',
|
||||
server: partialServerConfig.optional(),
|
||||
headscale: partialHeadscaleConfig.optional(),
|
||||
oidc: partialOidcConfig.optional(),
|
||||
integration: partialIntegrationConfig.optional(),
|
||||
debug: "boolean?",
|
||||
server: partialServerConfig.optional(),
|
||||
headscale: partialHeadscaleConfig.optional(),
|
||||
oidc: partialOidcConfig.optional(),
|
||||
integration: partialIntegrationConfig.optional(),
|
||||
});
|
||||
|
||||
export type HeadplaneConfig = typeof headplaneConfig.infer;
|
||||
export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer;
|
||||
|
||||
type DotNotationToObjects<
|
||||
T extends string,
|
||||
V,
|
||||
> = T extends `${infer K}.${infer Rest}`
|
||||
? { [P in K]?: DotNotationToObjects<Rest, V> }
|
||||
: { [P in `${T}_path`]?: V };
|
||||
type DotNotationToObjects<T extends string, V> = T extends `${infer K}.${infer Rest}`
|
||||
? { [P in K]?: DotNotationToObjects<Rest, V> }
|
||||
: { [P in `${T}_path`]?: V };
|
||||
|
||||
type ObjectDeepMerge<T> = T extends object
|
||||
? {
|
||||
[K in keyof T]: T[K] extends object ? ObjectDeepMerge<T[K]> : T[K];
|
||||
}
|
||||
: T;
|
||||
? {
|
||||
[K in keyof T]: T[K] extends object ? ObjectDeepMerge<T[K]> : T[K];
|
||||
}
|
||||
: T;
|
||||
|
||||
type ConfigWithPathKeys = ObjectDeepMerge<
|
||||
DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined>
|
||||
DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined>
|
||||
>;
|
||||
|
||||
export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig &
|
||||
ConfigWithPathKeys;
|
||||
export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig & ConfigWithPathKeys;
|
||||
|
||||
@@ -1,265 +1,167 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { platform } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { kill } from 'node:process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
||||
import { type } from 'arktype';
|
||||
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
|
||||
import log from '~/utils/log';
|
||||
import { Integration } from './abstract';
|
||||
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";
|
||||
import { type } from "arktype";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { Integration } from "./abstract";
|
||||
import { findHeadscaleServe, signalAndWaitHealthy } from "./proc-helper";
|
||||
|
||||
// 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 svcTokenPath = `${svcRoot}/token`;
|
||||
const svcNamespacePath = `${svcRoot}/namespace`;
|
||||
|
||||
const configSchema = {
|
||||
full: type({
|
||||
enabled: 'boolean',
|
||||
pod_name: 'string',
|
||||
validate_manifest: 'boolean = true',
|
||||
}),
|
||||
full: type({
|
||||
enabled: "boolean",
|
||||
pod_name: "string",
|
||||
validate_manifest: "boolean = true",
|
||||
}),
|
||||
|
||||
partial: type({
|
||||
enabled: 'boolean?',
|
||||
pod_name: 'string?',
|
||||
validate_manifest: 'boolean?',
|
||||
}).partial(),
|
||||
partial: type({
|
||||
enabled: "boolean?",
|
||||
pod_name: "string?",
|
||||
validate_manifest: "boolean?",
|
||||
}).partial(),
|
||||
};
|
||||
|
||||
export default class KubernetesIntegration extends Integration<
|
||||
typeof configSchema.full.infer
|
||||
> {
|
||||
private pid: number | undefined;
|
||||
private maxAttempts = 10;
|
||||
export default class KubernetesIntegration extends Integration<typeof configSchema.full.infer> {
|
||||
private pid: number | undefined;
|
||||
|
||||
get name() {
|
||||
return 'Kubernetes (k8s)';
|
||||
}
|
||||
get name() {
|
||||
return "Kubernetes (k8s)";
|
||||
}
|
||||
|
||||
static get configSchema() {
|
||||
return configSchema;
|
||||
}
|
||||
static get configSchema() {
|
||||
return configSchema;
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
if (platform() !== 'linux') {
|
||||
log.error('config', 'Kubernetes is only available on Linux');
|
||||
return false;
|
||||
}
|
||||
async isAvailable() {
|
||||
if (platform() !== "linux") {
|
||||
log.error("config", "Kubernetes is only available on Linux");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
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;
|
||||
}
|
||||
try {
|
||||
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;
|
||||
}
|
||||
|
||||
const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
|
||||
const expectedFiles = [svcCaPath, svcTokenPath, svcNamespacePath];
|
||||
const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
|
||||
const expectedFiles = [svcCaPath, svcTokenPath, svcNamespacePath];
|
||||
|
||||
log.debug('config', 'Looking for %s', expectedFiles.join(', '));
|
||||
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
|
||||
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("config", "Looking for %s", expectedFiles.join(", "));
|
||||
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
|
||||
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('config', 'Reading Kubernetes service account at %s', svcRoot);
|
||||
const namespace = await readFile(svcNamespacePath, 'utf8');
|
||||
log.debug("config", "Reading Kubernetes service account at %s", svcRoot);
|
||||
const namespace = await readFile(svcNamespacePath, "utf8");
|
||||
|
||||
// Some very ugly nesting but it's necessary
|
||||
if (this.context.validate_manifest === false) {
|
||||
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;
|
||||
}
|
||||
// Some very ugly nesting but it's necessary
|
||||
if (this.context.validate_manifest === false) {
|
||||
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.error('config', 'Pod name is empty');
|
||||
return false;
|
||||
}
|
||||
if (pod.trim().length === 0) {
|
||||
log.error("config", "Pod name is empty");
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
'config',
|
||||
'Checking Kubernetes pod %s in namespace %s',
|
||||
pod,
|
||||
namespace,
|
||||
);
|
||||
log.debug("config", "Checking Kubernetes pod %s in namespace %s", pod, namespace);
|
||||
|
||||
try {
|
||||
log.debug('config', 'Attempgin to get cluster KubeConfig');
|
||||
const kc = new KubeConfig();
|
||||
kc.loadFromCluster();
|
||||
try {
|
||||
log.debug("config", "Attempgin to get cluster KubeConfig");
|
||||
const kc = new KubeConfig();
|
||||
kc.loadFromCluster();
|
||||
|
||||
const cluster = kc.getCurrentCluster();
|
||||
if (!cluster) {
|
||||
log.error('config', 'Malformed kubeconfig');
|
||||
return false;
|
||||
}
|
||||
const cluster = kc.getCurrentCluster();
|
||||
if (!cluster) {
|
||||
log.error("config", "Malformed kubeconfig");
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info(
|
||||
'config',
|
||||
'Service account connected to %s (%s)',
|
||||
cluster.name,
|
||||
cluster.server,
|
||||
);
|
||||
log.info("config", "Service account connected to %s (%s)", cluster.name, cluster.server);
|
||||
|
||||
const kCoreV1Api = kc.makeApiClient(CoreV1Api);
|
||||
const kCoreV1Api = kc.makeApiClient(CoreV1Api);
|
||||
|
||||
log.info('config', 'Checking pod %s in namespace %s', pod, namespace);
|
||||
log.debug('config', 'Reading pod info for %s', pod);
|
||||
const body = await kCoreV1Api.readNamespacedPod({
|
||||
name: pod,
|
||||
namespace,
|
||||
});
|
||||
log.info("config", "Checking pod %s in namespace %s", pod, namespace);
|
||||
log.debug("config", "Reading pod info for %s", pod);
|
||||
const body = await kCoreV1Api.readNamespacedPod({
|
||||
name: pod,
|
||||
namespace,
|
||||
});
|
||||
|
||||
if (!body.spec) {
|
||||
log.error(
|
||||
'config',
|
||||
'Missing spec in pod info for %s/%s',
|
||||
pod,
|
||||
namespace,
|
||||
);
|
||||
if (!body.spec) {
|
||||
log.error("config", "Missing spec in pod info for %s/%s", pod, namespace);
|
||||
|
||||
return false;
|
||||
}
|
||||
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.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");
|
||||
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!shared) {
|
||||
log.error(
|
||||
'config',
|
||||
'Pod has set but disabled spec.shareProcessNamespace',
|
||||
);
|
||||
if (!shared) {
|
||||
log.error("config", "Pod has set but disabled spec.shareProcessNamespace");
|
||||
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info('config', 'Pod %s enabled shared processes', pod);
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to read pod info: %s', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
log.info("config", "Pod %s enabled shared processes", pod);
|
||||
} catch (error) {
|
||||
log.error("config", "Failed to read pod info: %s", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('config', 'Looking for namespaced process in /proc');
|
||||
const dir = resolve('/proc');
|
||||
try {
|
||||
const subdirs = await readdir(dir);
|
||||
const promises = subdirs.map(async (dir) => {
|
||||
const pid = Number.parseInt(dir, 10);
|
||||
try {
|
||||
const result = await findHeadscaleServe();
|
||||
if (!result) {
|
||||
log.error("config", "Could not find headscale serve process");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number.isNaN(pid)) {
|
||||
return;
|
||||
}
|
||||
this.pid = result;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const path = join('/proc', dir, 'comm');
|
||||
try {
|
||||
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()}`,
|
||||
);
|
||||
}
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
if (!this.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
return pid;
|
||||
} catch (error) {
|
||||
log.debug('config', 'Failed to read %s: %s', path, error);
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const pids = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
pids.push(result.value);
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('config', 'Found Headscale processes: %o', pids);
|
||||
if (pids.length > 1) {
|
||||
log.error(
|
||||
'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: RuntimeApiClient) {
|
||||
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.isHealthy();
|
||||
if (status === false) {
|
||||
throw new Error('Headscale is not running');
|
||||
}
|
||||
|
||||
log.info('config', 'Headscale is up and running');
|
||||
return;
|
||||
} catch {
|
||||
if (attempts < this.maxAttempts) {
|
||||
attempts++;
|
||||
await setTimeout(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
log.error(
|
||||
'config',
|
||||
'Missed restart deadline for Headscale (pid %d)',
|
||||
this.pid,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await signalAndWaitHealthy(client, {
|
||||
pid: this.pid,
|
||||
signal: "SIGHUP",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,175 +1,64 @@
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { platform } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { kill } from 'node:process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { type } from 'arktype';
|
||||
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
|
||||
import log from '~/utils/log';
|
||||
import type { HeadplaneConfig } from '../config-schema';
|
||||
import { Integration } from './abstract';
|
||||
import { type } from "arktype";
|
||||
import { platform } from "node:os";
|
||||
|
||||
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { Integration } from "./abstract";
|
||||
import { findHeadscaleServe, signalAndWaitHealthy } from "./proc-helper";
|
||||
|
||||
const configSchema = {
|
||||
full: type({
|
||||
enabled: 'boolean',
|
||||
}),
|
||||
full: type({
|
||||
enabled: "boolean",
|
||||
}),
|
||||
|
||||
partial: type({
|
||||
enabled: 'boolean?',
|
||||
}).partial(),
|
||||
partial: type({
|
||||
enabled: "boolean?",
|
||||
}).partial(),
|
||||
};
|
||||
|
||||
export default class ProcIntegration extends Integration<
|
||||
typeof configSchema.full.infer
|
||||
> {
|
||||
private pid: number | undefined;
|
||||
private maxAttempts = 10;
|
||||
export default class ProcIntegration extends Integration<typeof configSchema.full.infer> {
|
||||
private pid: number | undefined;
|
||||
|
||||
get name() {
|
||||
return 'Native Linux (/proc)';
|
||||
}
|
||||
get name() {
|
||||
return "Native Linux (/proc)";
|
||||
}
|
||||
|
||||
static get configSchema() {
|
||||
return configSchema;
|
||||
}
|
||||
static get configSchema() {
|
||||
return configSchema;
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
if (platform() !== 'linux') {
|
||||
log.error('config', '/proc is only available on Linux');
|
||||
return false;
|
||||
}
|
||||
async isAvailable() {
|
||||
if (platform() !== "linux") {
|
||||
log.error("config", "/proc is only available on Linux");
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug('config', 'Checking /proc for Headscale process');
|
||||
const dir = resolve('/proc');
|
||||
try {
|
||||
const subdirs = await readdir(dir);
|
||||
const promises = subdirs.map(async (dir) => {
|
||||
const pid = Number.parseInt(dir, 10);
|
||||
try {
|
||||
const result = await findHeadscaleServe();
|
||||
if (!result) {
|
||||
log.error("config", "Could not find headscale serve process");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Number.isNaN(pid)) {
|
||||
return;
|
||||
}
|
||||
this.pid = result;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const path = join('/proc', dir, 'comm');
|
||||
try {
|
||||
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()}`,
|
||||
);
|
||||
}
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
if (!this.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
return pid;
|
||||
} catch (error) {
|
||||
log.debug('config', 'Failed to read %s: %s', path, error);
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.allSettled(promises);
|
||||
const pids = [];
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
pids.push(result.value);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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: RuntimeApiClient) {
|
||||
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.isHealthy();
|
||||
if (status === false) {
|
||||
log.error('config', 'Headscale is not running');
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('config', 'Headscale is up and running');
|
||||
return;
|
||||
} catch {
|
||||
if (attempts < this.maxAttempts) {
|
||||
attempts++;
|
||||
await setTimeout(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
log.error(
|
||||
'config',
|
||||
'Missed restart deadline for Headscale (pid %d)',
|
||||
this.pid,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await signalAndWaitHealthy(client, {
|
||||
pid: this.pid,
|
||||
signal: "SIGHUP",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+39
-41
@@ -1,50 +1,48 @@
|
||||
import { eq, isNotNull } from 'drizzle-orm';
|
||||
import log from '~/utils/log';
|
||||
import type { Route } from '../../layouts/+types/dashboard';
|
||||
import { ephemeralNodes } from './schema';
|
||||
import { eq, isNotNull } from "drizzle-orm";
|
||||
|
||||
export async function pruneEphemeralNodes({
|
||||
context,
|
||||
request,
|
||||
}: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const ephemerals = await context.db
|
||||
.select()
|
||||
.from(ephemeralNodes)
|
||||
.where(isNotNull(ephemeralNodes.node_key));
|
||||
import log from "~/utils/log";
|
||||
|
||||
if (ephemerals.length === 0) {
|
||||
log.debug('api', 'No ephemeral nodes to prune');
|
||||
return;
|
||||
}
|
||||
import type { Route } from "../../layouts/+types/dashboard";
|
||||
import { ephemeralNodes } from "./schema";
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const nodes = await api.getNodes();
|
||||
const toPrune = nodes.filter((node) => {
|
||||
if (node.online) {
|
||||
return false;
|
||||
}
|
||||
export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const ephemerals = await context.db
|
||||
.select()
|
||||
.from(ephemeralNodes)
|
||||
.where(isNotNull(ephemeralNodes.node_key));
|
||||
|
||||
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
|
||||
});
|
||||
if (ephemerals.length === 0) {
|
||||
log.debug("api", "No ephemeral nodes to prune");
|
||||
return;
|
||||
}
|
||||
|
||||
if (toPrune.length === 0) {
|
||||
log.debug('api', 'No SSH nodes to prune');
|
||||
return;
|
||||
}
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const nodes = await api.getNodes();
|
||||
const toPrune = nodes.filter((node) => {
|
||||
if (node.online) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Delete from the Headscale nodes list and then from the database
|
||||
const promises = toPrune.map((node) => {
|
||||
return async () => {
|
||||
log.debug('api', `Pruning node ${node.name}`);
|
||||
await api.deleteNode(node.id);
|
||||
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
|
||||
});
|
||||
|
||||
await context.db
|
||||
.delete(ephemeralNodes)
|
||||
.where(eq(ephemeralNodes.node_key, node.nodeKey));
|
||||
log.debug('api', `Node ${node.name} pruned successfully`);
|
||||
};
|
||||
});
|
||||
if (toPrune.length === 0) {
|
||||
log.debug("api", "No SSH nodes to prune");
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(promises.map((p) => p()));
|
||||
// Delete from the Headscale nodes list and then from the database
|
||||
const promises = toPrune.map((node) => {
|
||||
return async () => {
|
||||
log.debug("api", `Pruning node ${node.name}`);
|
||||
await api.deleteNode(node.id);
|
||||
|
||||
await context.db.delete(ephemeralNodes).where(eq(ephemeralNodes.node_key, node.nodeKey));
|
||||
log.debug("api", `Node ${node.name} pruned successfully`);
|
||||
};
|
||||
});
|
||||
|
||||
await Promise.all(promises.map((p) => p()));
|
||||
}
|
||||
|
||||
+37
-18
@@ -1,31 +1,50 @@
|
||||
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { HostInfo } from '~/types';
|
||||
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
|
||||
|
||||
export const ephemeralNodes = sqliteTable('ephemeral_nodes', {
|
||||
auth_key: text('auth_key').primaryKey(),
|
||||
node_key: text('node_key'),
|
||||
import { HostInfo } from "~/types";
|
||||
|
||||
export const ephemeralNodes = sqliteTable("ephemeral_nodes", {
|
||||
auth_key: text("auth_key").primaryKey(),
|
||||
node_key: text("node_key"),
|
||||
});
|
||||
|
||||
export type EphemeralNode = typeof ephemeralNodes.$inferSelect;
|
||||
export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert;
|
||||
|
||||
export const hostInfo = sqliteTable('host_info', {
|
||||
host_id: text('host_id').primaryKey(),
|
||||
payload: text('payload', { mode: 'json' }).$type<HostInfo>(),
|
||||
updated_at: integer('updated_at', { mode: 'timestamp' }).$default(
|
||||
() => new Date(),
|
||||
),
|
||||
export const hostInfo = sqliteTable("host_info", {
|
||||
host_id: text("host_id").primaryKey(),
|
||||
payload: text("payload", { mode: "json" }).$type<HostInfo>(),
|
||||
updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()),
|
||||
});
|
||||
|
||||
export type HostInfoRecord = typeof hostInfo.$inferSelect;
|
||||
export type HostInfoInsert = typeof hostInfo.$inferInsert;
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
sub: text('sub').notNull().unique(),
|
||||
caps: integer('caps').notNull().default(0),
|
||||
onboarded: integer('onboarded', { mode: 'boolean' }).notNull().default(false),
|
||||
export const users = sqliteTable("users", {
|
||||
id: text("id").primaryKey(),
|
||||
sub: text("sub").notNull().unique(),
|
||||
role: text("role").notNull().default("member"),
|
||||
headscale_user_id: text("headscale_user_id").unique(),
|
||||
onboarded: integer("onboarded", { mode: "boolean" }).notNull().default(false),
|
||||
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
|
||||
updated_at: integer("updated_at", { mode: "timestamp" }).$default(() => new Date()),
|
||||
last_login_at: integer("last_login_at", { mode: "timestamp" }),
|
||||
|
||||
// Deprecated: kept for migration compatibility, will be removed in 1.0
|
||||
caps: integer("caps").notNull().default(0),
|
||||
});
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type UserInsert = typeof users.$inferInsert;
|
||||
export type HeadplaneUser = typeof users.$inferSelect;
|
||||
export type HeadplaneUserInsert = typeof users.$inferInsert;
|
||||
|
||||
export const authSessions = sqliteTable("auth_sessions", {
|
||||
id: text("id").primaryKey(),
|
||||
kind: text("kind").notNull(), // 'oidc' | 'api_key'
|
||||
user_id: text("user_id"),
|
||||
api_key_hash: text("api_key_hash"),
|
||||
api_key_display: text("api_key_display"),
|
||||
expires_at: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
|
||||
});
|
||||
|
||||
export type AuthSessionRecord = typeof authSessions.$inferSelect;
|
||||
export type AuthSessionInsert = typeof authSessions.$inferInsert;
|
||||
|
||||
@@ -1,150 +1,168 @@
|
||||
import type { Machine } from '~/types';
|
||||
import { defineApiEndpoints } from '../factory';
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
import type { HeadscaleApiInterface } from "..";
|
||||
|
||||
import { defineApiEndpoints } from "../factory";
|
||||
|
||||
interface RawMachine extends Omit<Machine, "tags"> {
|
||||
tags?: string[];
|
||||
forcedTags?: string[];
|
||||
validTags?: string[];
|
||||
invalidTags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the tags of a RawMachine based on the Headscale version.
|
||||
*
|
||||
* @param client The Headscale API client helper.
|
||||
* @param node The RawMachine object to normalize.
|
||||
* @returns A Machine object with normalized tags.
|
||||
*/
|
||||
function normalizeTags(client: HeadscaleApiInterface["clientHelpers"], node: RawMachine): Machine {
|
||||
if (client.isAtleast("0.28.0")) {
|
||||
return { ...node, tags: node.tags ?? [] } as Machine;
|
||||
}
|
||||
|
||||
const tags = Array.from(new Set([...(node.forcedTags ?? []), ...(node.validTags ?? [])]));
|
||||
|
||||
return { ...node, tags } as Machine;
|
||||
}
|
||||
|
||||
export interface NodeEndpoints {
|
||||
/**
|
||||
* Retrieves all nodes (machines) from the Headscale instance.
|
||||
*
|
||||
* @returns An array of `Machine` objects representing the nodes.
|
||||
*/
|
||||
getNodes(): Promise<Machine[]>;
|
||||
/**
|
||||
* Retrieves all nodes (machines) from the Headscale instance.
|
||||
*
|
||||
* @returns An array of `Machine` objects representing the nodes.
|
||||
*/
|
||||
getNodes(): Promise<Machine[]>;
|
||||
|
||||
/**
|
||||
* Retrieves a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to retrieve.
|
||||
* @returns A `Machine` object representing the node.
|
||||
*/
|
||||
getNode(id: string): Promise<Machine>;
|
||||
/**
|
||||
* Retrieves a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to retrieve.
|
||||
* @returns A `Machine` object representing the node.
|
||||
*/
|
||||
getNode(id: string): Promise<Machine>;
|
||||
|
||||
/**
|
||||
* Deletes a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to delete.
|
||||
*/
|
||||
deleteNode(id: string): Promise<void>;
|
||||
/**
|
||||
* Deletes a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to delete.
|
||||
*/
|
||||
deleteNode(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Registers a new node (machine) with the given user and key.
|
||||
*
|
||||
* @param user The user to associate with the node.
|
||||
* @param key The registration key for the node.
|
||||
* @returns A `Machine` object representing the newly registered node.
|
||||
*/
|
||||
registerNode(user: string, key: string): Promise<Machine>;
|
||||
/**
|
||||
* Registers a new node (machine) with the given user and key.
|
||||
*
|
||||
* @param user The user to associate with the node.
|
||||
* @param key The registration key for the node.
|
||||
* @returns A `Machine` object representing the newly registered node.
|
||||
*/
|
||||
registerNode(user: string, key: string): Promise<Machine>;
|
||||
|
||||
/**
|
||||
* Approves routes for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param routes An array of routes to approve for the node.
|
||||
*/
|
||||
approveNodeRoutes(id: string, routes: string[]): Promise<void>;
|
||||
/**
|
||||
* Approves routes for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param routes An array of routes to approve for the node.
|
||||
*/
|
||||
approveNodeRoutes(id: string, routes: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Expires a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to expire.
|
||||
*/
|
||||
expireNode(id: string): Promise<void>;
|
||||
/**
|
||||
* Expires a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to expire.
|
||||
*/
|
||||
expireNode(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Renames a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to rename.
|
||||
* @param newName The new name for the node.
|
||||
*/
|
||||
renameNode(id: string, newName: string): Promise<void>;
|
||||
/**
|
||||
* Renames a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to rename.
|
||||
* @param newName The new name for the node.
|
||||
*/
|
||||
renameNode(id: string, newName: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets tags for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param tags An array of tags to set for the node.
|
||||
*/
|
||||
setNodeTags(id: string, tags: string[]): Promise<void>;
|
||||
/**
|
||||
* Sets tags for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param tags An array of tags to set for the node.
|
||||
*/
|
||||
setNodeTags(id: string, tags: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets the user for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param user The user to set for the node.
|
||||
*/
|
||||
setNodeUser(id: string, user: string): Promise<void>;
|
||||
/**
|
||||
* Sets the user for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param user The user to set for the node.
|
||||
*/
|
||||
setNodeUser(id: string, user: string): Promise<void>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
|
||||
getNodes: async () => {
|
||||
const { nodes } = await client.apiFetch<{ nodes: Machine[] }>(
|
||||
'GET',
|
||||
'v1/node',
|
||||
apiKey,
|
||||
);
|
||||
getNodes: async () => {
|
||||
const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey);
|
||||
return nodes.map((node) => normalizeTags(client, node));
|
||||
},
|
||||
|
||||
return nodes;
|
||||
},
|
||||
getNode: async (nodeId) => {
|
||||
const { node } = await client.apiFetch<{ node: RawMachine }>(
|
||||
"GET",
|
||||
`v1/node/${nodeId}`,
|
||||
apiKey,
|
||||
);
|
||||
|
||||
getNode: async (nodeId) => {
|
||||
const { node } = await client.apiFetch<{ node: Machine }>(
|
||||
'GET',
|
||||
`v1/node/${nodeId}`,
|
||||
apiKey,
|
||||
);
|
||||
return normalizeTags(client, node);
|
||||
},
|
||||
|
||||
return node;
|
||||
},
|
||||
deleteNode: async (nodeId) => {
|
||||
await client.apiFetch<void>("DELETE", `v1/node/${nodeId}`, apiKey);
|
||||
},
|
||||
|
||||
deleteNode: async (nodeId) => {
|
||||
await client.apiFetch<void>('DELETE', `v1/node/${nodeId}`, apiKey);
|
||||
},
|
||||
registerNode: async (user, key) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.append("user", user);
|
||||
qp.append("key", key);
|
||||
const { node } = await client.apiFetch<{ node: RawMachine }>(
|
||||
"POST",
|
||||
`v1/node/register?${qp.toString()}`,
|
||||
apiKey,
|
||||
{
|
||||
user,
|
||||
key,
|
||||
},
|
||||
);
|
||||
|
||||
registerNode: async (user, key) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.append('user', user);
|
||||
qp.append('key', key);
|
||||
const { node } = await client.apiFetch<{ node: Machine }>(
|
||||
'POST',
|
||||
`v1/node/register?${qp.toString()}`,
|
||||
apiKey,
|
||||
{
|
||||
user,
|
||||
key,
|
||||
},
|
||||
);
|
||||
return normalizeTags(client, node);
|
||||
},
|
||||
|
||||
return node;
|
||||
},
|
||||
approveNodeRoutes: async (nodeId, routes) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/approve_routes`, apiKey, { routes });
|
||||
},
|
||||
|
||||
approveNodeRoutes: async (nodeId, routes) => {
|
||||
await client.apiFetch<void>(
|
||||
'POST',
|
||||
`v1/node/${nodeId}/approve_routes`,
|
||||
apiKey,
|
||||
{ routes },
|
||||
);
|
||||
},
|
||||
expireNode: async (nodeId) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/expire`, apiKey);
|
||||
},
|
||||
|
||||
expireNode: async (nodeId) => {
|
||||
await client.apiFetch<void>('POST', `v1/node/${nodeId}/expire`, apiKey);
|
||||
},
|
||||
renameNode: async (nodeId, newName) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey);
|
||||
},
|
||||
|
||||
renameNode: async (nodeId, newName) => {
|
||||
await client.apiFetch<void>(
|
||||
'POST',
|
||||
`v1/node/${nodeId}/rename/${newName}`,
|
||||
apiKey,
|
||||
);
|
||||
},
|
||||
setNodeTags: async (nodeId, tags) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/tags`, apiKey, {
|
||||
tags,
|
||||
});
|
||||
},
|
||||
|
||||
setNodeTags: async (nodeId, tags) => {
|
||||
await client.apiFetch<void>('POST', `v1/node/${nodeId}/tags`, apiKey, {
|
||||
tags,
|
||||
});
|
||||
},
|
||||
setNodeUser: async (nodeId, user) => {
|
||||
// Headscale 0.28.0 got rid of node reassignment to users
|
||||
if (client.isAtleast("0.28.0")) {
|
||||
return;
|
||||
}
|
||||
|
||||
setNodeUser: async (nodeId, user) => {
|
||||
await client.apiFetch<void>('POST', `v1/node/${nodeId}/user`, apiKey, {
|
||||
user,
|
||||
});
|
||||
},
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/user`, apiKey, {
|
||||
user,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -1,69 +1,85 @@
|
||||
import type { PreAuthKey } from '~/types';
|
||||
import { defineApiEndpoints } from '../factory';
|
||||
import type { PreAuthKey } from "~/types";
|
||||
|
||||
import { defineApiEndpoints } from "../factory";
|
||||
|
||||
export interface PreAuthKeyEndpoints {
|
||||
/**
|
||||
* Retrieves all pre-authentication keys for a specific user.
|
||||
*
|
||||
* @param user The user to retrieve pre-authentication keys for.
|
||||
* @returns An array of `PreAuthKey` objects representing the pre-authentication keys.
|
||||
*/
|
||||
getPreAuthKeys(user: string): Promise<PreAuthKey[]>;
|
||||
/**
|
||||
* List all pre-auth keys. Requires Headscale 0.28+.
|
||||
*/
|
||||
getAllPreAuthKeys(): Promise<PreAuthKey[]>;
|
||||
|
||||
/**
|
||||
* Creates a new pre-authentication key for a specific user.
|
||||
*
|
||||
* @param user The user to create the pre-authentication key for.
|
||||
* @param ephemeral Whether the key is ephemeral.
|
||||
* @param reusable Whether the key is reusable.
|
||||
* @param expiration The expiration date of the key, or `null` for no expiration.
|
||||
* @param aclTags An array of ACL tags to associate with the key, or `null` for none.
|
||||
* @returns A `PreAuthKey` object representing the newly created pre-authentication key.
|
||||
*/
|
||||
createPreAuthKey(
|
||||
user: string,
|
||||
ephemeral: boolean,
|
||||
reusable: boolean,
|
||||
expiration: Date | null,
|
||||
aclTags: string[] | null,
|
||||
): Promise<PreAuthKey>;
|
||||
/**
|
||||
* Retrieves all pre-authentication keys for a specific user.
|
||||
*
|
||||
* @param user The user to retrieve pre-authentication keys for.
|
||||
* @returns An array of `PreAuthKey` objects representing the pre-authentication keys.
|
||||
*/
|
||||
getPreAuthKeys(user: string): Promise<PreAuthKey[]>;
|
||||
|
||||
/**
|
||||
* Expires a specific pre-authentication key for a user.
|
||||
*
|
||||
* @param user The user associated with the pre-authentication key.
|
||||
* @param key The pre-authentication key to expire.
|
||||
*/
|
||||
expirePreAuthKey(user: string, key: string): Promise<void>;
|
||||
/**
|
||||
* Creates a new pre-authentication key.
|
||||
* User can be null for tag-only keys (requires Headscale 0.28+).
|
||||
*/
|
||||
createPreAuthKey(
|
||||
user: string | null,
|
||||
ephemeral: boolean,
|
||||
reusable: boolean,
|
||||
expiration: Date | null,
|
||||
aclTags: string[] | null,
|
||||
): Promise<PreAuthKey>;
|
||||
|
||||
/**
|
||||
* Expires a specific pre-authentication key for a user.
|
||||
*
|
||||
* @param user The user associated with the pre-authentication key.
|
||||
* @param key The pre-authentication key to expire.
|
||||
*/
|
||||
expirePreAuthKey(user: string, key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
|
||||
getPreAuthKeys: async (user) => {
|
||||
const { preAuthKeys } = await client.apiFetch<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>('GET', 'v1/preauthkey', apiKey, { user });
|
||||
getAllPreAuthKeys: async () => {
|
||||
const { preAuthKeys } = await client.apiFetch<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>("GET", "v1/preauthkey", apiKey, {});
|
||||
|
||||
return preAuthKeys;
|
||||
},
|
||||
return preAuthKeys;
|
||||
},
|
||||
|
||||
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
|
||||
const { preAuthKey } = await client.apiFetch<{
|
||||
preAuthKey: PreAuthKey;
|
||||
}>('POST', 'v1/preauthkey', apiKey, {
|
||||
user,
|
||||
ephemeral,
|
||||
reusable,
|
||||
expiration: expiration ? expiration.toISOString() : null,
|
||||
aclTags,
|
||||
});
|
||||
getPreAuthKeys: async (user) => {
|
||||
const { preAuthKeys } = await client.apiFetch<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>("GET", "v1/preauthkey", apiKey, { user });
|
||||
|
||||
return preAuthKey;
|
||||
},
|
||||
return preAuthKeys;
|
||||
},
|
||||
|
||||
expirePreAuthKey: async (user, key) => {
|
||||
await client.apiFetch<void>('POST', 'v1/preauthkey/expire', apiKey, {
|
||||
user,
|
||||
key,
|
||||
});
|
||||
},
|
||||
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
|
||||
const body: Record<string, unknown> = {
|
||||
ephemeral,
|
||||
reusable,
|
||||
expiration: expiration ? expiration.toISOString() : null,
|
||||
};
|
||||
|
||||
if (user) {
|
||||
body.user = user;
|
||||
}
|
||||
|
||||
if (aclTags && aclTags.length > 0) {
|
||||
body.aclTags = aclTags;
|
||||
}
|
||||
|
||||
const { preAuthKey } = await client.apiFetch<{
|
||||
preAuthKey: PreAuthKey;
|
||||
}>("POST", "v1/preauthkey", apiKey, body);
|
||||
|
||||
return preAuthKey;
|
||||
},
|
||||
|
||||
expirePreAuthKey: async (user, key) => {
|
||||
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
|
||||
user,
|
||||
key,
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -44,6 +44,13 @@ export function isConnectionError(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is a DataUnauthorizedError.
|
||||
* This checks if the error has a `data` property with a `statusCode` of 401.
|
||||
*
|
||||
* @param error - The error to check.
|
||||
* @returns True if the error is a DataUnauthorizedError, false otherwise.
|
||||
*/
|
||||
export function isDataUnauthorizedError(error: unknown): boolean {
|
||||
return (
|
||||
error != null &&
|
||||
@@ -55,3 +62,23 @@ export function isDataUnauthorizedError(error: unknown): boolean {
|
||||
error.data.statusCode === 401
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is a DataWithResponseInit wrapping a
|
||||
* HeadscaleAPIError. This is used in loaders/actions to handle errors thrown by
|
||||
* `data()` before they reach the ErrorBoundary.
|
||||
*
|
||||
* @param error - The error to check.
|
||||
* @returns True if the error is a DataWithResponseInit containing a
|
||||
* HeadscaleAPIError, false otherwise.
|
||||
*/
|
||||
export function isDataWithApiError(
|
||||
error: unknown,
|
||||
): error is { data: HeadscaleAPIError } {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'data' in error &&
|
||||
isApiError(error.data)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// Represents an error that occurred during a response
|
||||
// Thrown when status codes are >= 400
|
||||
export default class ResponseError extends Error {
|
||||
status: number;
|
||||
response: string;
|
||||
requestUrl: string;
|
||||
responseObject?: Record<string, unknown>;
|
||||
|
||||
constructor(status: number, response: string, requestUrl: string) {
|
||||
super(`${requestUrl}: status ${status} - ${response}`);
|
||||
this.name = 'ResponseError';
|
||||
this.status = status;
|
||||
this.response = response;
|
||||
this.requestUrl = requestUrl;
|
||||
|
||||
try {
|
||||
// Try to parse the response as JSON to get a response object
|
||||
this.responseObject = JSON.parse(response);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
+321
-349
@@ -1,179 +1,155 @@
|
||||
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
|
||||
import EventEmitter from 'node:events';
|
||||
import { access, constants, mkdir, open } from 'node:fs/promises';
|
||||
import { getegid, geteuid } from 'node:process';
|
||||
import { createInterface, Interface } from 'node:readline';
|
||||
import { inArray } from 'drizzle-orm';
|
||||
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver-core';
|
||||
import { HostInfo } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import { HeadplaneConfig } from './config/config-schema';
|
||||
import { hostInfo } from './db/schema';
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { LibSQLDatabase } from "drizzle-orm/libsql/driver-core";
|
||||
import { ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import EventEmitter from "node:events";
|
||||
import { access, constants, mkdir, open } from "node:fs/promises";
|
||||
import { getegid, geteuid } from "node:process";
|
||||
import { createInterface, Interface } from "node:readline";
|
||||
|
||||
import { HostInfo } from "~/types";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { HeadplaneConfig } from "./config/config-schema";
|
||||
import { hostInfo } from "./db/schema";
|
||||
|
||||
export async function createHeadplaneAgent(
|
||||
config: NonNullable<HeadplaneConfig['integration']>['agent'] | undefined,
|
||||
headscaleUrl: string,
|
||||
db: LibSQLDatabase,
|
||||
config: NonNullable<HeadplaneConfig["integration"]>["agent"] | undefined,
|
||||
headscaleUrl: string,
|
||||
db: LibSQLDatabase,
|
||||
) {
|
||||
if (!config?.enabled) {
|
||||
return;
|
||||
}
|
||||
if (!config?.enabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!config.pre_authkey) {
|
||||
log.error('agent', 'Agent `pre_authkey` is not set');
|
||||
log.warn('agent', 'The agent will not run until resolved');
|
||||
return;
|
||||
}
|
||||
if (!config.pre_authkey) {
|
||||
log.error("agent", "Agent `pre_authkey` is not set");
|
||||
log.warn("agent", "The agent will not run until resolved");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await access(config.work_dir, constants.R_OK | constants.W_OK);
|
||||
log.debug('config', 'Using agent work dir at %s', config.work_dir);
|
||||
} catch (error) {
|
||||
// Try to create the directory just in case
|
||||
try {
|
||||
await mkdir(config.work_dir, { recursive: true });
|
||||
log.debug('config', 'Created agent work dir at %s', config.work_dir);
|
||||
log.info(
|
||||
'config',
|
||||
'Created missing agent work dir at %s',
|
||||
config.work_dir,
|
||||
);
|
||||
try {
|
||||
await access(config.work_dir, constants.R_OK | constants.W_OK);
|
||||
log.debug("config", "Using agent work dir at %s", config.work_dir);
|
||||
} catch (error) {
|
||||
// Try to create the directory just in case
|
||||
try {
|
||||
await mkdir(config.work_dir, { recursive: true });
|
||||
log.debug("config", "Created agent work dir at %s", config.work_dir);
|
||||
log.info("config", "Created missing agent work dir at %s", config.work_dir);
|
||||
|
||||
return;
|
||||
} catch (innerError) {
|
||||
log.error(
|
||||
'config',
|
||||
'Failed to create agent work dir at %s',
|
||||
config.work_dir,
|
||||
);
|
||||
log.info(
|
||||
'config',
|
||||
'Agent work dir not accessible at %s',
|
||||
config.work_dir,
|
||||
);
|
||||
log.debug('config', 'Error details: %s', error);
|
||||
log.debug('config', 'Create error details: %s', innerError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
return;
|
||||
} catch (innerError) {
|
||||
log.error("config", "Failed to create agent work dir at %s", config.work_dir);
|
||||
log.info("config", "Agent work dir not accessible at %s", config.work_dir);
|
||||
log.debug("config", "Error details: %s", error);
|
||||
log.debug("config", "Create error details: %s", innerError);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await open(config.cache_path, 'a+');
|
||||
log.info('agent', 'Using agent cache file at %s', config.cache_path);
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
log.info(
|
||||
'agent',
|
||||
'Agent cache file not accessible at %s',
|
||||
config.cache_path,
|
||||
);
|
||||
log.debug('agent', 'Error details: %s', error);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const handle = await open(config.cache_path, "a+");
|
||||
log.info("agent", "Using agent cache file at %s", config.cache_path);
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
log.info("agent", "Agent cache file not accessible at %s", config.cache_path);
|
||||
log.debug("agent", "Error details: %s", error);
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = new HeadplaneAgent({
|
||||
...config,
|
||||
headscaleUrl,
|
||||
});
|
||||
const agent = new HeadplaneAgent({
|
||||
...config,
|
||||
headscaleUrl,
|
||||
});
|
||||
|
||||
agent.on('spawn', () => {
|
||||
log.info('agent', 'Headplane agent started');
|
||||
});
|
||||
agent.on("spawn", () => {
|
||||
log.info("agent", "Headplane agent started");
|
||||
});
|
||||
|
||||
agent.on('ready', () => {
|
||||
log.info('agent', 'Headplane agent is ready and serving queries');
|
||||
});
|
||||
agent.on("ready", () => {
|
||||
log.info("agent", "Headplane agent is ready and serving queries");
|
||||
});
|
||||
|
||||
agent.on('error', (err) => {
|
||||
log.warn('agent', 'Headplane agent experienced an error: %s', err.message);
|
||||
log.debug('agent', 'Error details: %o', err);
|
||||
});
|
||||
agent.on("error", (err) => {
|
||||
log.warn("agent", "Headplane agent experienced an error: %s", err.message);
|
||||
log.debug("agent", "Error details: %o", err);
|
||||
});
|
||||
|
||||
agent.on('exit', ({ code, signal }) => {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Headplane agent exited with code %s and signal %s',
|
||||
code,
|
||||
signal,
|
||||
);
|
||||
});
|
||||
agent.on("exit", ({ code, signal }) => {
|
||||
log.warn("agent", "Headplane agent exited with code %s and signal %s", code, signal);
|
||||
});
|
||||
|
||||
agent.on('restart', ({ delay, attempt }) => {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Headplane agent will restart in %f seconds (attempt %d)',
|
||||
delay / 1000,
|
||||
attempt,
|
||||
);
|
||||
});
|
||||
agent.on("restart", ({ delay, attempt }) => {
|
||||
log.warn(
|
||||
"agent",
|
||||
"Headplane agent will restart in %f seconds (attempt %d)",
|
||||
delay / 1000,
|
||||
attempt,
|
||||
);
|
||||
});
|
||||
|
||||
agent.on('stderr', (data) => {
|
||||
log.error('agent', 'Headplane agent stderr:', data);
|
||||
});
|
||||
agent.on("stderr", (data) => {
|
||||
log.error("agent", "Headplane agent stderr:", data);
|
||||
});
|
||||
|
||||
agent.on('info', async ({ id, info }) => {
|
||||
log.debug('agent', 'Received HostInfo for %s', id);
|
||||
try {
|
||||
const parsedInfo = JSON.parse(info) as HostInfo;
|
||||
await db
|
||||
.insert(hostInfo)
|
||||
.values({
|
||||
host_id: id,
|
||||
payload: parsedInfo,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: hostInfo.host_id,
|
||||
set: {
|
||||
payload: parsedInfo,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'agent',
|
||||
'Failed to parse HostInfo for %s: %s',
|
||||
id,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
agent.on("info", async ({ id, info }) => {
|
||||
log.debug("agent", "Received HostInfo for %s", id);
|
||||
try {
|
||||
const parsedInfo = JSON.parse(info) as HostInfo;
|
||||
await db
|
||||
.insert(hostInfo)
|
||||
.values({
|
||||
host_id: id,
|
||||
payload: parsedInfo,
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: hostInfo.host_id,
|
||||
set: {
|
||||
payload: parsedInfo,
|
||||
updated_at: new Date(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(
|
||||
"agent",
|
||||
"Failed to parse HostInfo for %s: %s",
|
||||
id,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
agent.start();
|
||||
agent.start();
|
||||
|
||||
process.on('SIGTERM', () => agent.shutdown());
|
||||
process.on('SIGINT', () => agent.shutdown());
|
||||
process.on("SIGTERM", () => agent.shutdown());
|
||||
process.on("SIGINT", () => agent.shutdown());
|
||||
|
||||
return {
|
||||
agentID: () => agent.agentID(),
|
||||
lookup: async (nodes: string[]) => {
|
||||
const results = await db
|
||||
.select()
|
||||
.from(hostInfo)
|
||||
.where(inArray(hostInfo.host_id, nodes));
|
||||
return {
|
||||
agentID: () => agent.agentID(),
|
||||
lookup: async (nodes: string[]) => {
|
||||
const results = await db.select().from(hostInfo).where(inArray(hostInfo.host_id, nodes));
|
||||
|
||||
return Object.fromEntries(
|
||||
results.filter((r) => r.payload).map((r) => [r.host_id, r.payload]),
|
||||
) as Record<string, HostInfo>;
|
||||
},
|
||||
};
|
||||
return Object.fromEntries(
|
||||
results.filter((r) => r.payload).map((r) => [r.host_id, r.payload]),
|
||||
) as Record<string, HostInfo>;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type AgentOptions = NonNullable<
|
||||
NonNullable<HeadplaneConfig['integration']>['agent']
|
||||
> & {
|
||||
headscaleUrl: string;
|
||||
type AgentOptions = NonNullable<NonNullable<HeadplaneConfig["integration"]>["agent"]> & {
|
||||
headscaleUrl: string;
|
||||
};
|
||||
|
||||
interface AgentEvents {
|
||||
ready: [];
|
||||
spawn: [];
|
||||
error: [Error];
|
||||
exit: [{ code?: number; signal?: NodeJS.Signals }];
|
||||
restart: [{ delay: number; attempt: number }];
|
||||
stderr: [string];
|
||||
info: [{ id: string; info: string }];
|
||||
ready: [];
|
||||
spawn: [];
|
||||
error: [Error];
|
||||
exit: [{ code?: number; signal?: NodeJS.Signals }];
|
||||
restart: [{ delay: number; attempt: number }];
|
||||
stderr: [string];
|
||||
info: [{ id: string; info: string }];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,237 +160,233 @@ interface AgentEvents {
|
||||
* - The agent is restarted on a backoff strategy
|
||||
*/
|
||||
class HeadplaneAgent extends EventEmitter<AgentEvents> {
|
||||
private child?: ChildProcessWithoutNullStreams;
|
||||
private readline?: Interface;
|
||||
private child?: ChildProcessWithoutNullStreams;
|
||||
private readline?: Interface;
|
||||
|
||||
private options: AgentOptions;
|
||||
private options: AgentOptions;
|
||||
|
||||
private hbInterval?: NodeJS.Timeout;
|
||||
private hbDeadline?: NodeJS.Timeout;
|
||||
private restartTimer?: NodeJS.Timeout;
|
||||
private isWaitingForAck = false;
|
||||
private isShuttingDown = false;
|
||||
private backoffAttempt = 0;
|
||||
private agentId?: string;
|
||||
private hbInterval?: NodeJS.Timeout;
|
||||
private hbDeadline?: NodeJS.Timeout;
|
||||
private restartTimer?: NodeJS.Timeout;
|
||||
private refreshInterval?: NodeJS.Timeout;
|
||||
private isWaitingForAck = false;
|
||||
private isShuttingDown = false;
|
||||
private backoffAttempt = 0;
|
||||
private agentId?: string;
|
||||
|
||||
private BASE_BACKOFF_MS = 1.5 * 1000; // 1.5 seconds
|
||||
private MAX_BACKOFF_MS = 30 * 1000; // 30 seconds
|
||||
private PROBE_COOLDOWN_MS = 5 * 60_000; // 5 minutes
|
||||
private PROBE_ATTEMPT_INTERVAL = 10; // Every 10th attempt
|
||||
private BASE_BACKOFF_MS = 1.5 * 1000; // 1.5 seconds
|
||||
private MAX_BACKOFF_MS = 30 * 1000; // 30 seconds
|
||||
private PROBE_COOLDOWN_MS = 5 * 60_000; // 5 minutes
|
||||
private PROBE_ATTEMPT_INTERVAL = 10; // Every 10th attempt
|
||||
|
||||
private HEARTBEAT_INTERVAL_MS = 5 * 1000; // 5 seconds
|
||||
private HEARTBEAT_TIMEOUT_MS = 3 * 1000; // 3 seconds
|
||||
private HEARTBEAT_INTERVAL_MS = 5 * 1000; // 5 seconds
|
||||
private HEARTBEAT_TIMEOUT_MS = 3 * 1000; // 3 seconds
|
||||
|
||||
constructor(options: AgentOptions) {
|
||||
super();
|
||||
this.options = options;
|
||||
}
|
||||
constructor(options: AgentOptions) {
|
||||
super();
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
agentID() {
|
||||
return this.agentId;
|
||||
}
|
||||
agentID() {
|
||||
return this.agentId;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.isShuttingDown = false;
|
||||
this.spawnInternalChild();
|
||||
}
|
||||
start() {
|
||||
this.isShuttingDown = false;
|
||||
this.spawnInternalChild();
|
||||
}
|
||||
|
||||
shutdown() {
|
||||
this.isShuttingDown = true;
|
||||
this.agentId = undefined;
|
||||
shutdown() {
|
||||
this.isShuttingDown = true;
|
||||
this.agentId = undefined;
|
||||
|
||||
clearTimeout(this.restartTimer);
|
||||
clearInterval(this.hbInterval);
|
||||
clearTimeout(this.hbDeadline);
|
||||
this.isWaitingForAck = false;
|
||||
clearTimeout(this.restartTimer);
|
||||
clearInterval(this.hbInterval);
|
||||
clearInterval(this.refreshInterval);
|
||||
clearTimeout(this.hbDeadline);
|
||||
this.isWaitingForAck = false;
|
||||
|
||||
this.send('SHUTDOWN');
|
||||
this.child?.kill('SIGTERM');
|
||||
this.readline?.close();
|
||||
}
|
||||
this.send("SHUTDOWN");
|
||||
this.child?.kill("SIGTERM");
|
||||
this.readline?.close();
|
||||
}
|
||||
|
||||
private spawnInternalChild() {
|
||||
this.child = spawn(this.options.executable_path, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
uid: geteuid?.() ?? undefined,
|
||||
gid: getegid?.() ?? undefined,
|
||||
env: {
|
||||
HOME: process.env.HOME,
|
||||
HEADPLANE_AGENT_WORK_DIR: this.options.work_dir,
|
||||
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? 'true' : 'false',
|
||||
HEADPLANE_AGENT_HOSTNAME: this.options.host_name,
|
||||
HEADPLANE_AGENT_TS_SERVER: this.options.headscaleUrl,
|
||||
HEADPLANE_AGENT_TS_AUTHKEY: this.options.pre_authkey,
|
||||
},
|
||||
});
|
||||
private spawnInternalChild() {
|
||||
this.child = spawn(this.options.executable_path, {
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
uid: geteuid?.() ?? undefined,
|
||||
gid: getegid?.() ?? undefined,
|
||||
env: {
|
||||
HOME: process.env.HOME,
|
||||
HEADPLANE_AGENT_WORK_DIR: this.options.work_dir,
|
||||
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? "true" : "false",
|
||||
HEADPLANE_AGENT_HOSTNAME: this.options.host_name,
|
||||
HEADPLANE_AGENT_TS_SERVER: this.options.headscaleUrl,
|
||||
HEADPLANE_AGENT_TS_AUTHKEY: this.options.pre_authkey,
|
||||
},
|
||||
});
|
||||
|
||||
this.emit('spawn');
|
||||
this.child.on('error', (err) => this.emit('error', err));
|
||||
this.child.stderr.on('data', (data) =>
|
||||
this.emit('stderr', data.toString()),
|
||||
);
|
||||
this.emit("spawn");
|
||||
this.child.on("error", (err) => this.emit("error", err));
|
||||
this.child.stderr.on("data", (data) => this.emit("stderr", data.toString()));
|
||||
|
||||
this.child.on('exit', (code, signal) => {
|
||||
this.agentId = undefined;
|
||||
this.emit('exit', {
|
||||
code: code ?? undefined,
|
||||
signal: signal ?? undefined,
|
||||
});
|
||||
this.child.on("exit", (code, signal) => {
|
||||
this.agentId = undefined;
|
||||
this.emit("exit", {
|
||||
code: code ?? undefined,
|
||||
signal: signal ?? undefined,
|
||||
});
|
||||
|
||||
this.readline?.close();
|
||||
clearInterval(this.hbInterval);
|
||||
clearTimeout(this.hbDeadline);
|
||||
this.isWaitingForAck = false;
|
||||
this.readline?.close();
|
||||
clearInterval(this.hbInterval);
|
||||
clearInterval(this.refreshInterval);
|
||||
clearTimeout(this.hbDeadline);
|
||||
this.isWaitingForAck = false;
|
||||
|
||||
if (this.isShuttingDown) {
|
||||
log.info('agent', 'Child process exited gracefully');
|
||||
return;
|
||||
}
|
||||
if (this.isShuttingDown) {
|
||||
log.info("agent", "Child process exited gracefully");
|
||||
return;
|
||||
}
|
||||
|
||||
this.backoffAttempt++;
|
||||
const delay = this.calculateBackoff();
|
||||
this.emit('restart', { delay, attempt: this.backoffAttempt });
|
||||
this.restartTimer = setTimeout(() => this.spawnInternalChild(), delay);
|
||||
});
|
||||
this.backoffAttempt++;
|
||||
const delay = this.calculateBackoff();
|
||||
this.emit("restart", { delay, attempt: this.backoffAttempt });
|
||||
this.restartTimer = setTimeout(() => this.spawnInternalChild(), delay);
|
||||
});
|
||||
|
||||
this.readline = createInterface({ input: this.child.stdout });
|
||||
this.readline.on('line', (line) => this.readlineHandler(line));
|
||||
this.send('START');
|
||||
this.readline = createInterface({ input: this.child.stdout });
|
||||
this.readline.on("line", (line) => this.readlineHandler(line));
|
||||
this.send("START");
|
||||
|
||||
// Start the heartbeat loop with our custom interval
|
||||
this.hbInterval = setInterval(() => {
|
||||
if (!this.child || this.child.killed) return;
|
||||
// Start the heartbeat loop with our custom interval
|
||||
this.hbInterval = setInterval(() => {
|
||||
if (!this.child || this.child.killed) return;
|
||||
|
||||
// If we get here, we missed the last PONG response and can die
|
||||
if (this.isWaitingForAck) {
|
||||
this.agentId = undefined;
|
||||
this.emit('error', new Error('Agent heartbeat missed'));
|
||||
this.child.kill('SIGTERM');
|
||||
return;
|
||||
}
|
||||
// If we get here, we missed the last PONG response and can die
|
||||
if (this.isWaitingForAck) {
|
||||
this.agentId = undefined;
|
||||
this.emit("error", new Error("Agent heartbeat missed"));
|
||||
this.child.kill("SIGTERM");
|
||||
return;
|
||||
}
|
||||
|
||||
this.isWaitingForAck = true;
|
||||
this.send('PING');
|
||||
this.isWaitingForAck = true;
|
||||
this.send("PING");
|
||||
|
||||
clearTimeout(this.hbDeadline);
|
||||
this.hbDeadline = setTimeout(() => {
|
||||
if (this.isWaitingForAck) {
|
||||
this.agentId = undefined;
|
||||
this.emit('error', new Error('Agent heartbeat timeout'));
|
||||
this.child?.kill('SIGTERM');
|
||||
}
|
||||
}, this.HEARTBEAT_TIMEOUT_MS);
|
||||
}, this.HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
clearTimeout(this.hbDeadline);
|
||||
this.hbDeadline = setTimeout(() => {
|
||||
if (this.isWaitingForAck) {
|
||||
this.agentId = undefined;
|
||||
this.emit("error", new Error("Agent heartbeat timeout"));
|
||||
this.child?.kill("SIGTERM");
|
||||
}
|
||||
}, this.HEARTBEAT_TIMEOUT_MS);
|
||||
}, this.HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private send(s: string) {
|
||||
if (!this.child || this.child.killed) return;
|
||||
const ok = this.child.stdin.write(`${s}\n`);
|
||||
if (!ok) this.child.stdin.once('drain', () => {});
|
||||
}
|
||||
private send(s: string) {
|
||||
if (!this.child || this.child.killed) return;
|
||||
const ok = this.child.stdin.write(`${s}\n`);
|
||||
if (!ok) this.child.stdin.once("drain", () => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates a backoff time based on the current attempt.
|
||||
* Supports a randomized jitter to avoid thundering herd problems.
|
||||
*
|
||||
* @param min The minimum backoff time in milliseconds.
|
||||
* @param max The maximum backoff time in milliseconds.
|
||||
* @returns The calculated backoff time in milliseconds.
|
||||
*/
|
||||
private calculateBackoff() {
|
||||
const attempt = this.backoffAttempt;
|
||||
if (attempt > 0 && attempt % this.PROBE_ATTEMPT_INTERVAL === 0) {
|
||||
const jitter = Math.floor(Math.random() * (this.MAX_BACKOFF_MS + 1));
|
||||
const sign = Math.random() < 0.5 ? -1 : 1;
|
||||
/**
|
||||
* Calculates a backoff time based on the current attempt.
|
||||
* Supports a randomized jitter to avoid thundering herd problems.
|
||||
*
|
||||
* @param min The minimum backoff time in milliseconds.
|
||||
* @param max The maximum backoff time in milliseconds.
|
||||
* @returns The calculated backoff time in milliseconds.
|
||||
*/
|
||||
private calculateBackoff() {
|
||||
const attempt = this.backoffAttempt;
|
||||
if (attempt > 0 && attempt % this.PROBE_ATTEMPT_INTERVAL === 0) {
|
||||
const jitter = Math.floor(Math.random() * (this.MAX_BACKOFF_MS + 1));
|
||||
const sign = Math.random() < 0.5 ? -1 : 1;
|
||||
|
||||
return Math.max(0, this.PROBE_COOLDOWN_MS + jitter * sign);
|
||||
}
|
||||
return Math.max(0, this.PROBE_COOLDOWN_MS + jitter * sign);
|
||||
}
|
||||
|
||||
const cap = Math.min(
|
||||
this.MAX_BACKOFF_MS,
|
||||
this.BASE_BACKOFF_MS * 2 ** attempt,
|
||||
);
|
||||
const cap = Math.min(this.MAX_BACKOFF_MS, this.BASE_BACKOFF_MS * 2 ** attempt);
|
||||
|
||||
return Math.floor(Math.random() * (cap + 1));
|
||||
}
|
||||
return Math.floor(Math.random() * (cap + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes and dispatches the appropriate response based on the message.
|
||||
* @param line The message to process (piped straight from readline)
|
||||
*/
|
||||
private readlineHandler(line: string) {
|
||||
// When we are ready we force a refresh so that the UI has the most
|
||||
// up-to-date information and will gracefully handle new info being sent
|
||||
if (line.startsWith('READY')) {
|
||||
this.backoffAttempt = 0;
|
||||
this.send('REFRESH');
|
||||
this.emit('ready');
|
||||
/**
|
||||
* Processes and dispatches the appropriate response based on the message.
|
||||
* @param line The message to process (piped straight from readline)
|
||||
*/
|
||||
private readlineHandler(line: string) {
|
||||
// When we are ready we force a refresh so that the UI has the most
|
||||
// up-to-date information and will gracefully handle new info being sent
|
||||
if (line.startsWith("READY")) {
|
||||
this.backoffAttempt = 0;
|
||||
this.send("REFRESH");
|
||||
this.emit("ready");
|
||||
|
||||
const agentId = line.slice(5).trim();
|
||||
if (this.agentId && this.agentId !== agentId) {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Agent ID changed from %s to %s',
|
||||
this.agentId,
|
||||
agentId,
|
||||
);
|
||||
}
|
||||
// Start periodic refresh using cache_ttl
|
||||
clearInterval(this.refreshInterval);
|
||||
this.refreshInterval = setInterval(() => {
|
||||
if (!this.child || this.child.killed) return;
|
||||
log.debug("agent", "Sending periodic REFRESH");
|
||||
this.send("REFRESH");
|
||||
}, this.options.cache_ttl);
|
||||
|
||||
this.agentId = agentId;
|
||||
return;
|
||||
}
|
||||
const agentId = line.slice(5).trim();
|
||||
if (this.agentId && this.agentId !== agentId) {
|
||||
log.warn("agent", "Agent ID changed from %s to %s", this.agentId, agentId);
|
||||
}
|
||||
|
||||
if (line.startsWith('PONG')) {
|
||||
this.isWaitingForAck = false;
|
||||
clearTimeout(this.hbDeadline);
|
||||
this.agentId = agentId;
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = line.slice(5).trim();
|
||||
if (this.agentId && this.agentId !== agentId) {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Agent ID changed from %s to %s',
|
||||
this.agentId,
|
||||
agentId,
|
||||
);
|
||||
}
|
||||
if (line.startsWith("PONG")) {
|
||||
this.isWaitingForAck = false;
|
||||
clearTimeout(this.hbDeadline);
|
||||
|
||||
this.agentId = agentId;
|
||||
return;
|
||||
}
|
||||
const agentId = line.slice(5).trim();
|
||||
if (this.agentId && this.agentId !== agentId) {
|
||||
log.warn("agent", "Agent ID changed from %s to %s", this.agentId, agentId);
|
||||
}
|
||||
|
||||
if (line.startsWith('HOSTINFO')) {
|
||||
const data = line.slice(9).trim();
|
||||
const [id, ...infoParts] = data.split(' ');
|
||||
const info = infoParts.join(' ');
|
||||
this.emit('info', { id, info });
|
||||
return;
|
||||
}
|
||||
this.agentId = agentId;
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('ERROR')) {
|
||||
const error = line.slice(6).trim();
|
||||
this.emit('error', new Error(error));
|
||||
return;
|
||||
}
|
||||
if (line.startsWith("HOSTINFO")) {
|
||||
const data = line.slice(9).trim();
|
||||
const [id, ...infoParts] = data.split(" ");
|
||||
const info = infoParts.join(" ");
|
||||
this.emit("info", { id, info });
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.startsWith('LOG')) {
|
||||
const logSnippet = line.slice(4).trim();
|
||||
const [level, ...messageParts] = logSnippet.split(' ');
|
||||
const message = messageParts.join(' ');
|
||||
switch (level) {
|
||||
case 'INFO':
|
||||
log.info('agent', message);
|
||||
break;
|
||||
case 'WARN':
|
||||
log.warn('agent', message);
|
||||
break;
|
||||
case 'ERROR':
|
||||
log.error('agent', message);
|
||||
break;
|
||||
default:
|
||||
log.debug('agent', message);
|
||||
}
|
||||
if (line.startsWith("ERROR")) {
|
||||
const error = line.slice(6).trim();
|
||||
this.emit("error", new Error(error));
|
||||
return;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (line.startsWith("LOG")) {
|
||||
const logSnippet = line.slice(4).trim();
|
||||
const [level, ...messageParts] = logSnippet.split(" ");
|
||||
const message = messageParts.join(" ");
|
||||
switch (level) {
|
||||
case "INFO":
|
||||
log.info("agent", message);
|
||||
break;
|
||||
case "WARN":
|
||||
log.warn("agent", message);
|
||||
break;
|
||||
case "ERROR":
|
||||
log.error("agent", message);
|
||||
break;
|
||||
default:
|
||||
log.debug("agent", message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+109
-103
@@ -1,142 +1,148 @@
|
||||
import { join } from 'node:path';
|
||||
import { exit, versions } from 'node:process';
|
||||
import { createHonoServer } from 'react-router-hono-server/node';
|
||||
import log from '~/utils/log';
|
||||
import { loadIntegration } from './config/integration';
|
||||
import { loadConfig } from './config/load';
|
||||
import { createDbClient } from './db/client.server';
|
||||
import { createHeadscaleInterface } from './headscale/api';
|
||||
import { loadHeadscaleConfig } from './headscale/config-loader';
|
||||
import { createHeadplaneAgent } from './hp-agent';
|
||||
import { createSessionStorage } from './web/sessions';
|
||||
import { join } from "node:path";
|
||||
import { exit, versions } from "node:process";
|
||||
|
||||
import { createHonoServer } from "react-router-hono-server/node";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { loadIntegration } from "./config/integration";
|
||||
import { loadConfig } from "./config/load";
|
||||
import { createDbClient } from "./db/client.server";
|
||||
import { createHeadscaleInterface } from "./headscale/api";
|
||||
import { loadHeadscaleConfig } from "./headscale/config-loader";
|
||||
import { createHeadplaneAgent } from "./hp-agent";
|
||||
import { createAuthService } from "./web/auth";
|
||||
|
||||
declare global {
|
||||
const __PREFIX__: string;
|
||||
const __VERSION__: string;
|
||||
const __PREFIX__: string;
|
||||
const __VERSION__: string;
|
||||
}
|
||||
|
||||
// MARK: Side-Effects
|
||||
// This module contains a side-effect because everything running here
|
||||
// exists for the lifetime of the process, making it appropriate.
|
||||
log.info('server', 'Running Node.js %s', versions.node);
|
||||
log.info("server", "Running Node.js %s", versions.node);
|
||||
let config: HeadplaneConfig;
|
||||
|
||||
try {
|
||||
config = await loadConfig();
|
||||
config = await loadConfig();
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
log.error('server', 'Unable to load configuration: %s', error.message);
|
||||
}
|
||||
if (error instanceof ConfigError) {
|
||||
log.error("server", "Unable to load configuration: %s", error.message);
|
||||
}
|
||||
|
||||
exit(1);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const db = await createDbClient(join(config.server.data_path, 'hp_persist.db'));
|
||||
const agents = await createHeadplaneAgent(
|
||||
config.integration?.agent,
|
||||
config.headscale.url,
|
||||
db,
|
||||
);
|
||||
const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));
|
||||
const agents = await createHeadplaneAgent(config.integration?.agent, config.headscale.url, db);
|
||||
|
||||
const hsApi = await createHeadscaleInterface(
|
||||
config.headscale.url,
|
||||
config.headscale.tls_cert_path,
|
||||
);
|
||||
const hsApi = await createHeadscaleInterface(config.headscale.url, config.headscale.tls_cert_path);
|
||||
|
||||
// We also use this file to load anything needed by the react router code.
|
||||
// These are usually per-request things that we need access to, like the
|
||||
// helper that can issue and revoke cookies.
|
||||
export type LoadContext = typeof appLoadContext;
|
||||
|
||||
import 'react-router';
|
||||
import { HeadplaneConfig } from './config/config-schema';
|
||||
import { ConfigError } from './config/error';
|
||||
import { createOidcConnector } from './web/oidc-connector';
|
||||
import "react-router";
|
||||
import { HeadplaneConfig } from "./config/config-schema";
|
||||
import { ConfigError } from "./config/error";
|
||||
import { createLazyOidcConnector } from "./web/oidc-connector";
|
||||
|
||||
declare module 'react-router' {
|
||||
interface AppLoadContext extends LoadContext {}
|
||||
declare module "react-router" {
|
||||
interface AppLoadContext extends LoadContext {}
|
||||
}
|
||||
|
||||
const appLoadContext = {
|
||||
config,
|
||||
hs: await loadHeadscaleConfig(
|
||||
config.headscale.config_path,
|
||||
config.headscale.config_strict,
|
||||
config.headscale.dns_records_path,
|
||||
),
|
||||
config,
|
||||
hs: await loadHeadscaleConfig(
|
||||
config.headscale.config_path,
|
||||
config.headscale.config_strict,
|
||||
config.headscale.dns_records_path,
|
||||
),
|
||||
|
||||
// TODO: Better cookie options in config
|
||||
sessions: await createSessionStorage({
|
||||
secret: config.server.cookie_secret,
|
||||
db,
|
||||
oidcUsersFile: config.oidc?.user_storage_file,
|
||||
cookie: {
|
||||
name: '_hp_auth',
|
||||
secure: config.server.cookie_secure,
|
||||
maxAge: config.server.cookie_max_age,
|
||||
domain: config.server.cookie_domain,
|
||||
},
|
||||
}),
|
||||
auth: createAuthService({
|
||||
secret: config.server.cookie_secret,
|
||||
db,
|
||||
cookie: {
|
||||
name: "_hp_auth",
|
||||
secure: config.server.cookie_secure,
|
||||
maxAge: config.server.cookie_max_age,
|
||||
domain: config.server.cookie_domain,
|
||||
},
|
||||
}),
|
||||
|
||||
hsApi,
|
||||
agents,
|
||||
integration: await loadIntegration(config.integration),
|
||||
oidcConnector: config.oidc
|
||||
? await createOidcConnector(
|
||||
config.server.base_url,
|
||||
config.oidc,
|
||||
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
|
||||
)
|
||||
: undefined,
|
||||
db,
|
||||
hsApi,
|
||||
agents,
|
||||
integration: await loadIntegration(config.integration),
|
||||
oidc:
|
||||
config.oidc && config.oidc.enabled !== false
|
||||
? {
|
||||
apiKey: config.oidc.headscale_api_key,
|
||||
connector: createLazyOidcConnector(
|
||||
config.server.base_url,
|
||||
config.oidc,
|
||||
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
db,
|
||||
};
|
||||
|
||||
declare module 'react-router' {
|
||||
interface AppLoadContext extends LoadContext {}
|
||||
declare module "react-router" {
|
||||
interface AppLoadContext extends LoadContext {}
|
||||
}
|
||||
|
||||
export default createHonoServer({
|
||||
overrideGlobalObjects: true,
|
||||
port: config.server.port,
|
||||
hostname: config.server.host,
|
||||
beforeAll: async (app) => {
|
||||
app.use(__PREFIX__, async (c) => {
|
||||
return c.redirect(`${__PREFIX__}/`);
|
||||
});
|
||||
},
|
||||
serveStaticOptions: {
|
||||
publicAssets: {
|
||||
// This is part of our monkey-patch for react-router-hono-server
|
||||
// To see the first part, go to the patches/ directory.
|
||||
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ''),
|
||||
},
|
||||
clientAssets: {
|
||||
// This is part of our monkey-patch for react-router-hono-server
|
||||
// To see the first part, go to the patches/ directory.
|
||||
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ''),
|
||||
},
|
||||
},
|
||||
overrideGlobalObjects: true,
|
||||
port: config.server.port,
|
||||
hostname: config.server.host,
|
||||
beforeAll: async (app) => {
|
||||
app.use(__PREFIX__, async (c) => {
|
||||
return c.redirect(`${__PREFIX__}/`);
|
||||
});
|
||||
},
|
||||
serveStaticOptions: {
|
||||
publicAssets: {
|
||||
// This is part of our monkey-patch for react-router-hono-server
|
||||
// To see the first part, go to the patches/ directory.
|
||||
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""),
|
||||
},
|
||||
clientAssets: {
|
||||
// This is part of our monkey-patch for react-router-hono-server
|
||||
// To see the first part, go to the patches/ directory.
|
||||
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""),
|
||||
},
|
||||
},
|
||||
|
||||
// Only log in development mode
|
||||
defaultLogger: import.meta.env.DEV,
|
||||
getLoadContext() {
|
||||
// TODO: This is the place where we can handle reverse proxy translation
|
||||
// This is better than doing it in the OIDC client, since we can do it
|
||||
// for all requests, not just OIDC ones.
|
||||
return appLoadContext;
|
||||
},
|
||||
// Only log in development mode
|
||||
defaultLogger: import.meta.env.DEV,
|
||||
getLoadContext() {
|
||||
// TODO: This is the place where we can handle reverse proxy translation
|
||||
// This is better than doing it in the OIDC client, since we can do it
|
||||
// for all requests, not just OIDC ones.
|
||||
return appLoadContext;
|
||||
},
|
||||
|
||||
listeningListener(info) {
|
||||
log.info('server', 'Running on %s:%s', info.address, info.port);
|
||||
},
|
||||
listeningListener(info) {
|
||||
log.info("server", "Running on %s:%s", info.address, info.port);
|
||||
},
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
log.info('server', 'Received SIGINT, shutting down...');
|
||||
process.exit(0);
|
||||
// Prune expired auth sessions every 15 minutes
|
||||
setInterval(
|
||||
() => {
|
||||
appLoadContext.auth.pruneExpiredSessions();
|
||||
},
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
log.info("server", "Received SIGINT, shutting down...");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
log.info('server', 'Received SIGTERM, shutting down...');
|
||||
process.exit(0);
|
||||
process.on("SIGTERM", () => {
|
||||
log.info("server", "Received SIGTERM, shutting down...");
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,480 @@
|
||||
import { createHash, createHmac } from "node:crypto";
|
||||
|
||||
import { eq, lt, sql } from "drizzle-orm";
|
||||
import { LibSQLDatabase } from "drizzle-orm/libsql/driver";
|
||||
import { createCookie } from "react-router";
|
||||
import { ulid } from "ulidx";
|
||||
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
import { authSessions, users } from "../db/schema";
|
||||
import { Capabilities, type Role, Roles, capsForRole } from "./roles";
|
||||
|
||||
// ── Principal ────────────────────────────────────────────────────────
|
||||
// The per-request identity object. Discriminated on `kind` so routes
|
||||
// can branch structurally instead of checking magic strings.
|
||||
|
||||
export type Principal =
|
||||
| {
|
||||
kind: "api_key";
|
||||
sessionId: string;
|
||||
displayName: string;
|
||||
apiKey: string;
|
||||
}
|
||||
| {
|
||||
kind: "oidc";
|
||||
sessionId: string;
|
||||
user: {
|
||||
id: string;
|
||||
subject: string;
|
||||
role: Role;
|
||||
headscaleUserId: string | undefined;
|
||||
onboarded: boolean;
|
||||
};
|
||||
profile: {
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
};
|
||||
|
||||
// ── Cookie payload ───────────────────────────────────────────────────
|
||||
// The cookie contains only a session ID + minimal profile data for
|
||||
// SSR rendering. Credentials never leave the server.
|
||||
|
||||
interface CookiePayload {
|
||||
sid: string;
|
||||
// API key is stored in the cookie ONLY for api_key sessions.
|
||||
// OIDC sessions use the server-side oidc.headscale_api_key.
|
||||
api_key?: string;
|
||||
profile?: {
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// ── AuthService ──────────────────────────────────────────────────────
|
||||
|
||||
export interface AuthServiceOptions {
|
||||
secret: string;
|
||||
db: LibSQLDatabase;
|
||||
cookie: {
|
||||
name: string;
|
||||
secure: boolean;
|
||||
maxAge: number;
|
||||
domain?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export class AuthService {
|
||||
private opts: AuthServiceOptions;
|
||||
private requestCache = new WeakMap<Request, Promise<Principal>>();
|
||||
|
||||
constructor(opts: AuthServiceOptions) {
|
||||
this.opts = opts;
|
||||
}
|
||||
|
||||
// ── Authentication ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the principal for a request. Throws if no valid session.
|
||||
* Results are cached per-request so multiple calls in the same
|
||||
* loader don't hit the DB repeatedly.
|
||||
*/
|
||||
require(request: Request): Promise<Principal> {
|
||||
const cached = this.requestCache.get(request);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const promise = this.resolve(request);
|
||||
this.requestCache.set(request, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
private async resolve(request: Request): Promise<Principal> {
|
||||
const payload = await this.decodeCookie(request);
|
||||
|
||||
const [session] = await this.opts.db
|
||||
.select()
|
||||
.from(authSessions)
|
||||
.where(eq(authSessions.id, payload.sid))
|
||||
.limit(1);
|
||||
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
|
||||
if (session.expires_at < new Date()) {
|
||||
await this.opts.db.delete(authSessions).where(eq(authSessions.id, session.id));
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
|
||||
if (session.kind === "api_key") {
|
||||
if (!payload.api_key) {
|
||||
throw new Error("API key session missing credential");
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "api_key",
|
||||
sessionId: session.id,
|
||||
displayName: session.api_key_display ?? "API Key",
|
||||
apiKey: payload.api_key,
|
||||
};
|
||||
}
|
||||
|
||||
if (!session.user_id) {
|
||||
throw new Error("OIDC session missing user_id");
|
||||
}
|
||||
|
||||
const [user] = await this.opts.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, session.user_id))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
throw new Error("User record not found");
|
||||
}
|
||||
|
||||
const role = (user.role in Roles ? user.role : "member") as Role;
|
||||
return {
|
||||
kind: "oidc",
|
||||
sessionId: session.id,
|
||||
user: {
|
||||
id: user.id,
|
||||
subject: user.sub,
|
||||
role,
|
||||
headscaleUserId: user.headscale_user_id ?? undefined,
|
||||
onboarded: user.onboarded,
|
||||
},
|
||||
profile: payload.profile ?? {
|
||||
name: user.sub,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Authorization ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a principal has a given set of capabilities.
|
||||
* API key principals always have full access.
|
||||
*/
|
||||
can(principal: Principal, capabilities: Capabilities): boolean {
|
||||
if (principal.kind === "api_key") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const roleCaps = Roles[principal.user.role];
|
||||
return (capabilities & roleCaps) === capabilities;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a principal can act on a machine. Owners of the machine
|
||||
* can act on it even without write_machines capability.
|
||||
*/
|
||||
canManageNode(principal: Principal, node: Machine): boolean {
|
||||
if (principal.kind === "api_key") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const caps = Roles[principal.user.role];
|
||||
if ((caps & Capabilities.write_machines) !== 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const hsUserId = principal.user.headscaleUserId;
|
||||
return hsUserId !== undefined && node.user?.id === hsUserId;
|
||||
}
|
||||
|
||||
// ── Session management ─────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Create a new OIDC session. Returns the Set-Cookie header value.
|
||||
*/
|
||||
async createOidcSession(
|
||||
userId: string,
|
||||
profile: NonNullable<CookiePayload["profile"]>,
|
||||
maxAge = this.opts.cookie.maxAge,
|
||||
): Promise<string> {
|
||||
const sid = ulid();
|
||||
await this.opts.db.insert(authSessions).values({
|
||||
id: sid,
|
||||
kind: "oidc",
|
||||
user_id: userId,
|
||||
expires_at: new Date(Date.now() + maxAge * 1000),
|
||||
});
|
||||
|
||||
return this.encodeCookie({ sid, profile }, maxAge);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new API key session. A SHA-256 hash of the key is stored
|
||||
* server-side for auditing. The plaintext key is carried in the
|
||||
* HMAC-signed cookie so it can be used for Headscale API calls.
|
||||
* Returns the Set-Cookie header value.
|
||||
*/
|
||||
async createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string> {
|
||||
const sid = ulid();
|
||||
await this.opts.db.insert(authSessions).values({
|
||||
id: sid,
|
||||
kind: "api_key",
|
||||
api_key_hash: this.hashApiKey(apiKey),
|
||||
api_key_display: displayName,
|
||||
expires_at: new Date(Date.now() + maxAge),
|
||||
});
|
||||
|
||||
return this.encodeCookie({ sid, api_key: apiKey }, Math.floor(maxAge / 1000));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Headscale API key for making API calls.
|
||||
* OIDC sessions use the configured oidc.headscale_api_key.
|
||||
* API key sessions use the user-provided key stored in the cookie.
|
||||
*/
|
||||
getHeadscaleApiKey(principal: Principal, oidcApiKey?: string): string {
|
||||
if (principal.kind === "api_key") {
|
||||
return principal.apiKey;
|
||||
}
|
||||
|
||||
if (!oidcApiKey) {
|
||||
throw new Error("OIDC sessions require oidc.headscale_api_key");
|
||||
}
|
||||
|
||||
return oidcApiKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the current session. Returns the Set-Cookie header that
|
||||
* clears the cookie.
|
||||
*/
|
||||
async destroySession(request?: Request): Promise<string> {
|
||||
if (request) {
|
||||
try {
|
||||
const payload = await this.decodeCookie(request);
|
||||
await this.opts.db.delete(authSessions).where(eq(authSessions.id, payload.sid));
|
||||
} catch {
|
||||
// Cookie already invalid, just clear it
|
||||
}
|
||||
}
|
||||
|
||||
const cookie = createCookie(this.opts.cookie.name, {
|
||||
...this.opts.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
return cookie.serialize("", { expires: new Date(0) });
|
||||
}
|
||||
|
||||
// ── User management ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Find or create a Headplane user by OIDC subject. Returns the
|
||||
* user ID. The first user ever created is automatically granted
|
||||
* the owner role (bootstrap). Uses upsert to avoid race conditions.
|
||||
*/
|
||||
async findOrCreateUser(subject: string): Promise<string> {
|
||||
const [existing] = await this.opts.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, subject))
|
||||
.limit(1);
|
||||
|
||||
if (existing) {
|
||||
await this.opts.db
|
||||
.update(users)
|
||||
.set({ last_login_at: new Date(), updated_at: new Date() })
|
||||
.where(eq(users.id, existing.id));
|
||||
return existing.id;
|
||||
}
|
||||
|
||||
const id = ulid();
|
||||
await this.opts.db.insert(users).values({
|
||||
id,
|
||||
sub: subject,
|
||||
role: "member",
|
||||
caps: capsForRole("member"),
|
||||
onboarded: false,
|
||||
});
|
||||
|
||||
// If this is the only user in the table, promote to owner.
|
||||
// The unique constraint on `sub` prevents two concurrent inserts
|
||||
// for the same subject; for different subjects, COUNT atomically
|
||||
// reflects all committed rows so at most one will see count === 1.
|
||||
const [{ count }] = await this.opts.db.select({ count: sql<number>`count(*)` }).from(users);
|
||||
|
||||
if (count === 1) {
|
||||
await this.opts.db
|
||||
.update(users)
|
||||
.set({ role: "owner", caps: capsForRole("owner") })
|
||||
.where(eq(users.id, id));
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a Headplane user to a Headscale user. Returns false if the
|
||||
* Headscale user is already claimed by another Headplane user.
|
||||
*/
|
||||
async linkHeadscaleUser(userId: string, headscaleUserId: string): Promise<boolean> {
|
||||
const [existing] = await this.opts.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.headscale_user_id, headscaleUserId))
|
||||
.limit(1);
|
||||
|
||||
if (existing && existing.id !== userId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.opts.db
|
||||
.update(users)
|
||||
.set({ headscale_user_id: headscaleUserId, updated_at: new Date() })
|
||||
.where(eq(users.id, userId));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link a Headplane user (identified by OIDC subject) to a Headscale
|
||||
* user. Used by admin UI when subjects are more accessible than
|
||||
* internal Headplane IDs. Returns false if already claimed.
|
||||
*/
|
||||
async linkHeadscaleUserBySubject(subject: string, headscaleUserId: string): Promise<boolean> {
|
||||
const [user] = await this.opts.db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.sub, subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.linkHeadscaleUser(user.id, headscaleUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the set of Headscale user IDs that are already claimed
|
||||
* by a Headplane user. Used to filter the onboarding dropdown.
|
||||
*/
|
||||
async claimedHeadscaleUserIds(): Promise<Set<string>> {
|
||||
const rows = await this.opts.db.select({ hsId: users.headscale_user_id }).from(users);
|
||||
|
||||
const ids = new Set<string>();
|
||||
for (const row of rows) {
|
||||
if (row.hsId) {
|
||||
ids.add(row.hsId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the role for a given OIDC subject. Used by the users overview
|
||||
* to display roles for Headscale users.
|
||||
*/
|
||||
async roleForSubject(subject: string): Promise<Role | undefined> {
|
||||
const [user] = await this.opts.db.select().from(users).where(eq(users.sub, subject)).limit(1);
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (user.role in Roles ? user.role : "member") as Role;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassign the role of a user identified by their OIDC subject.
|
||||
* Cannot reassign the owner role.
|
||||
*/
|
||||
async reassignSubject(subject: string, role: Role): Promise<boolean> {
|
||||
const currentRole = await this.roleForSubject(subject);
|
||||
if (currentRole === "owner") {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.opts.db
|
||||
.insert(users)
|
||||
.values({
|
||||
id: ulid(),
|
||||
sub: subject,
|
||||
role,
|
||||
caps: capsForRole(role),
|
||||
onboarded: false,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: users.sub,
|
||||
set: { role, caps: capsForRole(role), updated_at: new Date() },
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up expired sessions. Should be called periodically.
|
||||
*/
|
||||
async pruneExpiredSessions(): Promise<void> {
|
||||
await this.opts.db.delete(authSessions).where(lt(authSessions.expires_at, new Date()));
|
||||
}
|
||||
|
||||
// ── Private helpers ────────────────────────────────────────────
|
||||
|
||||
private async encodeCookie(payload: CookiePayload, maxAge: number): Promise<string> {
|
||||
const cookie = createCookie(this.opts.cookie.name, {
|
||||
...this.opts.cookie,
|
||||
path: __PREFIX__,
|
||||
maxAge,
|
||||
});
|
||||
|
||||
const signed = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||
const hmac = createHmac("sha256", this.opts.secret).update(signed).digest("base64url");
|
||||
|
||||
return cookie.serialize(`${signed}.${hmac}`);
|
||||
}
|
||||
|
||||
private async decodeCookie(request: Request): Promise<CookiePayload> {
|
||||
const cookieHeader = request.headers.get("cookie");
|
||||
if (!cookieHeader) {
|
||||
throw new Error("No session cookie found");
|
||||
}
|
||||
|
||||
const cookie = createCookie(this.opts.cookie.name, {
|
||||
...this.opts.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
const raw = (await cookie.parse(cookieHeader)) as string | null;
|
||||
if (!raw) {
|
||||
throw new Error("Session cookie is empty");
|
||||
}
|
||||
|
||||
const dotIndex = raw.lastIndexOf(".");
|
||||
if (dotIndex === -1) {
|
||||
throw new Error("Malformed session cookie");
|
||||
}
|
||||
|
||||
const signed = raw.slice(0, dotIndex);
|
||||
const hmac = raw.slice(dotIndex + 1);
|
||||
|
||||
const expected = createHmac("sha256", this.opts.secret).update(signed).digest("base64url");
|
||||
|
||||
if (hmac !== expected) {
|
||||
throw new Error("Invalid session cookie signature");
|
||||
}
|
||||
|
||||
return JSON.parse(Buffer.from(signed, "base64url").toString("utf-8")) as CookiePayload;
|
||||
}
|
||||
|
||||
private hashApiKey(key: string): string {
|
||||
return createHash("sha256").update(key).digest("hex");
|
||||
}
|
||||
}
|
||||
|
||||
export function createAuthService(opts: AuthServiceOptions): AuthService {
|
||||
return new AuthService(opts);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { User } from "~/types/User";
|
||||
|
||||
/**
|
||||
* Extracts the OIDC subject from a Headscale user's providerId.
|
||||
* Headscale stores providerId as a URL where the last path segment
|
||||
* is the subject (e.g. "https://idp.example.com/<uuid>"). This is
|
||||
* the ONLY place this parsing should occur — all other code should
|
||||
* use the stable headscale_user_id link on the Headplane user record.
|
||||
*/
|
||||
export function getOidcSubject(user: User): string | undefined {
|
||||
if (user.provider !== "oidc" || !user.providerId) {
|
||||
return;
|
||||
}
|
||||
|
||||
return user.providerId.split("/").pop();
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds the Headscale user matching the given OIDC identity.
|
||||
* Tries subject match first (providerId last segment), then falls
|
||||
* back to email match. The fallback is needed because some IDPs
|
||||
* issue different subjects per client application.
|
||||
*/
|
||||
export function findHeadscaleUserBySubject(
|
||||
users: User[],
|
||||
subject: string,
|
||||
email?: string,
|
||||
): User | undefined {
|
||||
const bySubject = users.find((u) => getOidcSubject(u) === subject);
|
||||
if (bySubject) {
|
||||
return bySubject;
|
||||
}
|
||||
|
||||
if (!email) {
|
||||
return;
|
||||
}
|
||||
|
||||
return users.find((u) => u.email === email);
|
||||
}
|
||||
+270
-161
@@ -1,41 +1,116 @@
|
||||
import * as oidc from 'openid-client';
|
||||
import log from '~/utils/log';
|
||||
import type { HeadplaneConfig } from '../config/config-schema';
|
||||
import type { RuntimeApiClient } from '../headscale/api/endpoints';
|
||||
import { isDataUnauthorizedError } from '../headscale/api/error-client';
|
||||
import * as oidc from "openid-client";
|
||||
|
||||
export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { HeadplaneConfig } from "../config/config-schema";
|
||||
import type { RuntimeApiClient } from "../headscale/api/endpoints";
|
||||
|
||||
import { isDataUnauthorizedError } from "../headscale/api/error-client";
|
||||
|
||||
export type OidcConfig = NonNullable<HeadplaneConfig["oidc"]>;
|
||||
|
||||
/**
|
||||
* Errors that can occur during OIDC connector setup and validation.
|
||||
*/
|
||||
export type OidcConnectorError =
|
||||
| 'INVALID_API_KEY'
|
||||
| 'MISSING_AUTHORIZATION_ENDPOINT'
|
||||
| 'MISSING_TOKEN_ENDPOINT'
|
||||
| 'MISSING_USERINFO_ENDPOINT'
|
||||
| 'MISSING_REQUIRED_CLAIMS'
|
||||
| 'UNKNOWN_ERROR';
|
||||
| "INVALID_API_KEY"
|
||||
| "MISSING_AUTHORIZATION_ENDPOINT"
|
||||
| "MISSING_TOKEN_ENDPOINT"
|
||||
| "MISSING_USERINFO_ENDPOINT"
|
||||
| "MISSING_REQUIRED_CLAIMS"
|
||||
| "DISCOVERY_FAILED"
|
||||
| "UNKNOWN_ERROR";
|
||||
|
||||
/**
|
||||
* Represents a "configured" OIDC setup for Headplane.
|
||||
* This may include mis-configured versions too and will surface error messages.
|
||||
*/
|
||||
export type OidcConnector =
|
||||
| {
|
||||
isValid: true;
|
||||
isExclusive: boolean;
|
||||
usePKCE: boolean;
|
||||
client: oidc.Configuration;
|
||||
apiKey: string;
|
||||
scope: string;
|
||||
extraParams?: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
isValid: false;
|
||||
isExclusive: false;
|
||||
errors: OidcConnectorError[];
|
||||
};
|
||||
| {
|
||||
isValid: true;
|
||||
isExclusive: boolean;
|
||||
usePKCE: boolean;
|
||||
client: oidc.Configuration;
|
||||
apiKey: string;
|
||||
scope: string;
|
||||
extraParams?: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
isValid: false;
|
||||
isExclusive: false;
|
||||
errors: OidcConnectorError[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A lazy OIDC connector that retries initialization on failure.
|
||||
* This allows OIDC to recover from transient startup failures (e.g., network issues,
|
||||
* OIDC provider temporarily unavailable) without requiring a server restart.
|
||||
*/
|
||||
export interface LazyOidcConnector {
|
||||
/**
|
||||
* Get the current OIDC connector state.
|
||||
* If a previous attempt failed, this will retry initialization.
|
||||
* Successful results are cached until invalidated.
|
||||
*/
|
||||
get(): Promise<OidcConnector>;
|
||||
|
||||
/**
|
||||
* Force a re-initialization of the OIDC connector on the next get() call.
|
||||
* Useful for manually triggering a retry after configuration changes.
|
||||
*/
|
||||
invalidate(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a lazy OIDC connector that retries on failure.
|
||||
* Successful initialization is cached; failed attempts are retried on each get() call.
|
||||
*
|
||||
* @param baseUrl The base URL of the Headplane server.
|
||||
* @param config The OIDC configuration.
|
||||
* @param client The Headscale runtime API client.
|
||||
* @returns A lazy OIDC connector that retries on failure.
|
||||
*/
|
||||
export function createLazyOidcConnector(
|
||||
baseUrl: string | undefined,
|
||||
config: OidcConfig,
|
||||
client: RuntimeApiClient,
|
||||
): LazyOidcConnector {
|
||||
let cachedConnector: OidcConnector | undefined;
|
||||
let initPromise: Promise<OidcConnector> | undefined;
|
||||
|
||||
return {
|
||||
async get(): Promise<OidcConnector> {
|
||||
if (cachedConnector?.isValid) {
|
||||
return cachedConnector;
|
||||
}
|
||||
|
||||
if (initPromise) {
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
initPromise = createOidcConnector(baseUrl, config, client);
|
||||
try {
|
||||
const connector = await initPromise;
|
||||
if (connector.isValid) {
|
||||
cachedConnector = connector;
|
||||
log.info("auth", "OIDC connector initialized successfully");
|
||||
} else {
|
||||
log.warn("auth", "OIDC connector initialization failed, will retry on next request");
|
||||
}
|
||||
return connector;
|
||||
} finally {
|
||||
// Clear the promise so we can retry on next call if it failed
|
||||
initPromise = undefined;
|
||||
}
|
||||
},
|
||||
|
||||
invalidate(): void {
|
||||
cachedConnector = undefined;
|
||||
initPromise = undefined;
|
||||
log.info("auth", "OIDC connector cache invalidated");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an OIDC connector based on the configuration and Headscale API.
|
||||
@@ -46,63 +121,63 @@ export type OidcConnector =
|
||||
* @param client The Headscale runtime API client.
|
||||
* @returns An OIDC connector with validation status.
|
||||
*/
|
||||
export async function createOidcConnector(
|
||||
baseUrl: string | undefined,
|
||||
config: OidcConfig,
|
||||
client: RuntimeApiClient,
|
||||
async function createOidcConnector(
|
||||
baseUrl: string | undefined,
|
||||
config: OidcConfig,
|
||||
client: RuntimeApiClient,
|
||||
): Promise<OidcConnector> {
|
||||
if (baseUrl == null && config.redirect_uri == null) {
|
||||
log.warn(
|
||||
'config',
|
||||
'OIDC is enabled but `server.base_url` is not set in the config. Starting in Headplane 0.7.0 this will be required for OIDC to function properly and will throw errors if not set, see https://headplane.net/features/sso#configuring-oidc for more information.',
|
||||
);
|
||||
}
|
||||
if (baseUrl == null && config.redirect_uri == null) {
|
||||
log.warn(
|
||||
"config",
|
||||
"OIDC is enabled but `server.base_url` is not set in the config. Starting in Headplane 0.7.0 this will be required for OIDC to function properly and will throw errors if not set, see https://headplane.net/features/sso#configuring-oidc for more information.",
|
||||
);
|
||||
}
|
||||
|
||||
const errors: OidcConnectorError[] = [];
|
||||
if (!config.headscale_api_key) {
|
||||
errors.push('INVALID_API_KEY');
|
||||
return {
|
||||
isValid: false,
|
||||
isExclusive: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
const errors: OidcConnectorError[] = [];
|
||||
if (!config.headscale_api_key) {
|
||||
errors.push("INVALID_API_KEY");
|
||||
return {
|
||||
isValid: false,
|
||||
isExclusive: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await client.getApiKeys();
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
errors.push('INVALID_API_KEY');
|
||||
return {
|
||||
isValid: false,
|
||||
isExclusive: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
try {
|
||||
await client.getApiKeys();
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
errors.push("INVALID_API_KEY");
|
||||
return {
|
||||
isValid: false,
|
||||
isExclusive: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// MARK: Otherwise assume the API key is valid since the API request
|
||||
// failed for another reason that isn't 401 and we are optimistic
|
||||
}
|
||||
// MARK: Otherwise assume the API key is valid since the API request
|
||||
// failed for another reason that isn't 401 and we are optimistic
|
||||
}
|
||||
|
||||
const oidcClientOrErrors = await discoveryCoalesce(config);
|
||||
if (Array.isArray(oidcClientOrErrors)) {
|
||||
errors.push(...oidcClientOrErrors);
|
||||
return {
|
||||
isValid: false,
|
||||
isExclusive: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
const oidcClientOrErrors = await discoveryCoalesce(config);
|
||||
if (Array.isArray(oidcClientOrErrors)) {
|
||||
errors.push(...oidcClientOrErrors);
|
||||
return {
|
||||
isValid: false,
|
||||
isExclusive: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
isExclusive: config.disable_api_key_login,
|
||||
usePKCE: config.use_pkce,
|
||||
client: oidcClientOrErrors,
|
||||
apiKey: config.headscale_api_key,
|
||||
scope: config.scope,
|
||||
extraParams: config.extra_params,
|
||||
};
|
||||
return {
|
||||
isValid: true,
|
||||
isExclusive: config.disable_api_key_login,
|
||||
usePKCE: config.use_pkce,
|
||||
client: oidcClientOrErrors,
|
||||
apiKey: config.headscale_api_key,
|
||||
scope: config.scope,
|
||||
extraParams: config.extra_params,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -113,101 +188,135 @@ export async function createOidcConnector(
|
||||
* @returns The coalesced OIDC configuration or an array of errors.
|
||||
*/
|
||||
async function discoveryCoalesce(
|
||||
config: OidcConfig,
|
||||
config: OidcConfig,
|
||||
): Promise<oidc.Configuration | OidcConnectorError[]> {
|
||||
let metadata: oidc.ServerMetadata;
|
||||
try {
|
||||
const client = await oidc.discovery(
|
||||
new URL(config.issuer),
|
||||
config.client_id,
|
||||
);
|
||||
metadata = client.serverMetadata();
|
||||
if (config.use_pkce === true && !client.serverMetadata().supportsPKCE()) {
|
||||
log.warn(
|
||||
'config',
|
||||
'OIDC provider does not support PKCE, but it is enabled in the config',
|
||||
);
|
||||
}
|
||||
let metadata: oidc.ServerMetadata;
|
||||
let discoveryFailed = false;
|
||||
|
||||
if (metadata.claims_supported != null) {
|
||||
if (!metadata.claims_supported.includes('sub')) {
|
||||
log.error('config', 'OIDC provider does not support `sub` claim');
|
||||
return ['MISSING_REQUIRED_CLAIMS'];
|
||||
}
|
||||
try {
|
||||
const client = await oidc.discovery(new URL(config.issuer), config.client_id);
|
||||
metadata = client.serverMetadata();
|
||||
if (config.use_pkce === true && !client.serverMetadata().supportsPKCE()) {
|
||||
log.warn("config", "OIDC provider does not support PKCE, but it is enabled in the config");
|
||||
}
|
||||
|
||||
if (!metadata.claims_supported.includes('name')) {
|
||||
if (
|
||||
!(
|
||||
metadata.claims_supported.includes('given_name') &&
|
||||
metadata.claims_supported.includes('family_name')
|
||||
)
|
||||
) {
|
||||
log.warn(
|
||||
'config',
|
||||
'OIDC provider does not support `name`, `given_name`, or `family_name` claims',
|
||||
);
|
||||
}
|
||||
}
|
||||
if (metadata.claims_supported != null) {
|
||||
if (!metadata.claims_supported.includes("sub")) {
|
||||
log.error("config", "OIDC provider does not support `sub` claim");
|
||||
return ["MISSING_REQUIRED_CLAIMS"];
|
||||
}
|
||||
|
||||
if (
|
||||
!metadata.claims_supported.includes('preferred_username') &&
|
||||
!metadata.claims_supported.includes('email')
|
||||
) {
|
||||
log.warn(
|
||||
'config',
|
||||
'OIDC provider does not support `preferred_username` or `email` claims',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
log.error(
|
||||
'config',
|
||||
'Failed to auto-configure OIDC endpoints via discovery',
|
||||
);
|
||||
log.warn(
|
||||
'config',
|
||||
'OIDC server may not support discovery, using manual config',
|
||||
);
|
||||
metadata = {
|
||||
issuer: config.issuer,
|
||||
};
|
||||
}
|
||||
if (!metadata.claims_supported.includes("name")) {
|
||||
if (
|
||||
!(
|
||||
metadata.claims_supported.includes("given_name") &&
|
||||
metadata.claims_supported.includes("family_name")
|
||||
)
|
||||
) {
|
||||
log.warn(
|
||||
"config",
|
||||
"OIDC provider does not support `name`, `given_name`, or `family_name` claims",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const errors: OidcConnectorError[] = [];
|
||||
const authorization_endpoint =
|
||||
config.authorization_endpoint ?? metadata.authorization_endpoint;
|
||||
if (
|
||||
!metadata.claims_supported.includes("preferred_username") &&
|
||||
!metadata.claims_supported.includes("email")
|
||||
) {
|
||||
log.warn("config", "OIDC provider does not support `preferred_username` or `email` claims");
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
log.warn("oidc", "Failed to reach OIDC provider for discovery, will retry on next request");
|
||||
discoveryFailed = true;
|
||||
metadata = {
|
||||
issuer: config.issuer,
|
||||
};
|
||||
}
|
||||
|
||||
const token_endpoint = config.token_endpoint ?? metadata.token_endpoint;
|
||||
const authorization_endpoint = config.authorization_endpoint ?? metadata.authorization_endpoint;
|
||||
const token_endpoint = config.token_endpoint ?? metadata.token_endpoint;
|
||||
const userinfo_endpoint = config.userinfo_endpoint ?? metadata.userinfo_endpoint;
|
||||
|
||||
const userinfo_endpoint =
|
||||
config.userinfo_endpoint ?? metadata.userinfo_endpoint;
|
||||
const hasMissingEndpoints = !authorization_endpoint || !token_endpoint || !userinfo_endpoint;
|
||||
|
||||
if (!authorization_endpoint) {
|
||||
errors.push('MISSING_AUTHORIZATION_ENDPOINT');
|
||||
}
|
||||
if (discoveryFailed && hasMissingEndpoints) {
|
||||
return ["DISCOVERY_FAILED"];
|
||||
}
|
||||
|
||||
if (!token_endpoint) {
|
||||
errors.push('MISSING_TOKEN_ENDPOINT');
|
||||
}
|
||||
const errors: OidcConnectorError[] = [];
|
||||
|
||||
if (!userinfo_endpoint) {
|
||||
errors.push('MISSING_USERINFO_ENDPOINT');
|
||||
}
|
||||
if (!authorization_endpoint) {
|
||||
errors.push("MISSING_AUTHORIZATION_ENDPOINT");
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return errors;
|
||||
}
|
||||
if (!token_endpoint) {
|
||||
errors.push("MISSING_TOKEN_ENDPOINT");
|
||||
}
|
||||
|
||||
const oidcClient = new oidc.Configuration(
|
||||
{
|
||||
issuer: config.issuer,
|
||||
authorization_endpoint,
|
||||
token_endpoint,
|
||||
userinfo_endpoint,
|
||||
},
|
||||
config.client_id,
|
||||
config.client_secret,
|
||||
);
|
||||
if (!userinfo_endpoint) {
|
||||
errors.push("MISSING_USERINFO_ENDPOINT");
|
||||
}
|
||||
|
||||
return oidcClient;
|
||||
if (errors.length > 0) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const oidcClient = new oidc.Configuration(
|
||||
{
|
||||
...metadata,
|
||||
issuer: config.issuer,
|
||||
authorization_endpoint,
|
||||
token_endpoint,
|
||||
userinfo_endpoint,
|
||||
},
|
||||
config.client_id,
|
||||
config.client_secret,
|
||||
negotiateTokenEndpointAuthMethod(config, metadata),
|
||||
);
|
||||
|
||||
return oidcClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines the token endpoint authentication method based on config and metadata.
|
||||
*
|
||||
* @param config The OIDC configuration.
|
||||
* @param metadata The OIDC server metadata.
|
||||
* @returns The client authentication method for the token endpoint.
|
||||
*/
|
||||
function negotiateTokenEndpointAuthMethod(
|
||||
config: OidcConfig,
|
||||
metadata: oidc.ServerMetadata,
|
||||
): oidc.ClientAuth {
|
||||
if (config.token_endpoint_auth_method != null) {
|
||||
switch (config.token_endpoint_auth_method) {
|
||||
case "client_secret_basic":
|
||||
return oidc.ClientSecretBasic(config.client_secret);
|
||||
case "client_secret_post":
|
||||
return oidc.ClientSecretPost(config.client_secret);
|
||||
case "client_secret_jwt":
|
||||
return oidc.ClientSecretJwt(config.client_secret);
|
||||
}
|
||||
}
|
||||
|
||||
const supported = metadata.token_endpoint_auth_methods_supported;
|
||||
if (supported != null && supported.length > 0) {
|
||||
// Prefer client_secret_basic (spec default), otherwise use first available
|
||||
if (supported.includes("client_secret_basic")) {
|
||||
return oidc.ClientSecretBasic(config.client_secret);
|
||||
}
|
||||
|
||||
if (supported.includes("client_secret_post")) {
|
||||
return oidc.ClientSecretPost(config.client_secret);
|
||||
}
|
||||
|
||||
if (supported.includes("client_secret_jwt")) {
|
||||
return oidc.ClientSecretJwt(config.client_secret);
|
||||
}
|
||||
}
|
||||
|
||||
log.warn("config", "Falling back to client_secret_post for token endpoint authentication");
|
||||
return oidc.ClientSecretPost(config.client_secret);
|
||||
}
|
||||
|
||||
+103
-124
@@ -1,144 +1,123 @@
|
||||
export type Capabilities = (typeof Capabilities)[keyof typeof Capabilities];
|
||||
export const Capabilities = {
|
||||
// Can access the admin console
|
||||
ui_access: 1 << 0,
|
||||
|
||||
// Read tailnet policy file (unimplemented)
|
||||
read_policy: 1 << 1,
|
||||
|
||||
// Write tailnet policy file (unimplemented)
|
||||
write_policy: 1 << 2,
|
||||
|
||||
// Read network configurations
|
||||
read_network: 1 << 3,
|
||||
|
||||
// Write network configurations, for example, enable MagicDNS, split DNS,
|
||||
// make subnet, or allow a node to be an exit node, enable HTTPS
|
||||
write_network: 1 << 4,
|
||||
|
||||
// Read feature configuration (unimplemented)
|
||||
read_feature: 1 << 5,
|
||||
|
||||
// Write feature configuration, for example, enable Taildrop (unimplemented)
|
||||
write_feature: 1 << 6,
|
||||
|
||||
// Configure user & group provisioning
|
||||
configure_iam: 1 << 7,
|
||||
|
||||
// Read machines, for example, see machine names and status
|
||||
read_machines: 1 << 8,
|
||||
|
||||
// Write machines, for example, approve, rename, and remove machines
|
||||
write_machines: 1 << 9,
|
||||
|
||||
// Read users and user roles
|
||||
read_users: 1 << 10,
|
||||
|
||||
// Write users and user roles, for example, remove users,
|
||||
// approve users, make Admin
|
||||
write_users: 1 << 11,
|
||||
|
||||
// Can generate authkeys (unimplemented)
|
||||
generate_authkeys: 1 << 12,
|
||||
|
||||
// Can use any tag (without being tag owner) (unimplemented)
|
||||
use_tags: 1 << 13,
|
||||
|
||||
// Write tailnet name (unimplemented)
|
||||
write_tailnet: 1 << 14,
|
||||
|
||||
// Owner flag
|
||||
owner: 1 << 15,
|
||||
ui_access: 1 << 0,
|
||||
read_policy: 1 << 1,
|
||||
write_policy: 1 << 2,
|
||||
read_network: 1 << 3,
|
||||
write_network: 1 << 4,
|
||||
read_feature: 1 << 5,
|
||||
write_feature: 1 << 6,
|
||||
configure_iam: 1 << 7,
|
||||
read_machines: 1 << 8,
|
||||
write_machines: 1 << 9,
|
||||
read_users: 1 << 10,
|
||||
write_users: 1 << 11,
|
||||
generate_authkeys: 1 << 12,
|
||||
generate_own_authkeys: 1 << 16,
|
||||
use_tags: 1 << 13,
|
||||
write_tailnet: 1 << 14,
|
||||
owner: 1 << 15,
|
||||
} as const;
|
||||
|
||||
export type Roles = [keyof typeof Roles];
|
||||
export const Roles = {
|
||||
owner:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.write_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.write_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.write_feature |
|
||||
Capabilities.configure_iam |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.write_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.write_users |
|
||||
Capabilities.generate_authkeys |
|
||||
Capabilities.use_tags |
|
||||
Capabilities.write_tailnet |
|
||||
Capabilities.owner,
|
||||
owner:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.write_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.write_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.write_feature |
|
||||
Capabilities.configure_iam |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.write_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.write_users |
|
||||
Capabilities.generate_authkeys |
|
||||
Capabilities.use_tags |
|
||||
Capabilities.write_tailnet |
|
||||
Capabilities.owner,
|
||||
|
||||
admin:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.write_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.write_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.write_feature |
|
||||
Capabilities.configure_iam |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.write_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.write_users |
|
||||
Capabilities.generate_authkeys |
|
||||
Capabilities.use_tags |
|
||||
Capabilities.write_tailnet,
|
||||
admin:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.write_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.write_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.write_feature |
|
||||
Capabilities.configure_iam |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.write_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.write_users |
|
||||
Capabilities.generate_authkeys |
|
||||
Capabilities.use_tags |
|
||||
Capabilities.write_tailnet,
|
||||
|
||||
network_admin:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.write_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.write_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.generate_authkeys |
|
||||
Capabilities.use_tags |
|
||||
Capabilities.write_tailnet,
|
||||
network_admin:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.write_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.write_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.generate_authkeys |
|
||||
Capabilities.use_tags |
|
||||
Capabilities.write_tailnet,
|
||||
|
||||
it_admin:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.write_feature |
|
||||
Capabilities.configure_iam |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.write_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.write_users |
|
||||
Capabilities.generate_authkeys,
|
||||
it_admin:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.write_feature |
|
||||
Capabilities.configure_iam |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.write_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.write_users |
|
||||
Capabilities.generate_authkeys,
|
||||
|
||||
auditor:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.read_users,
|
||||
auditor:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.generate_own_authkeys,
|
||||
|
||||
// Default role for new users with 0 capabilities on the UI side of things
|
||||
member: 0,
|
||||
viewer:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.generate_own_authkeys,
|
||||
|
||||
// No access — user exists but has not been granted any role
|
||||
member: 0,
|
||||
} as const;
|
||||
|
||||
export type Role = keyof typeof Roles;
|
||||
export type Capability = keyof typeof Capabilities;
|
||||
|
||||
export function hasCapability(role: Role, capability: Capability): boolean {
|
||||
return (Roles[role] & Capabilities[capability]) !== 0;
|
||||
return (Roles[role] & Capabilities[capability]) !== 0;
|
||||
}
|
||||
|
||||
export function getRoleFromCapabilities(capabilities: Capabilities): Role {
|
||||
const iterable = Roles as Record<string, Capabilities>;
|
||||
for (const role in iterable) {
|
||||
if (iterable[role] === capabilities) {
|
||||
return role as Role;
|
||||
}
|
||||
}
|
||||
const iterable = Roles as Record<string, Capabilities>;
|
||||
for (const role in iterable) {
|
||||
if (iterable[role] === capabilities) {
|
||||
return role as Role;
|
||||
}
|
||||
}
|
||||
|
||||
return 'member';
|
||||
return "member";
|
||||
}
|
||||
|
||||
export function capsForRole(role: Role): number {
|
||||
return Roles[role];
|
||||
}
|
||||
|
||||
@@ -1,317 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { open, readFile, rm } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver';
|
||||
import { EncryptJWT, jwtDecrypt } from 'jose';
|
||||
import { createCookie } from 'react-router';
|
||||
import { ulid } from 'ulidx';
|
||||
import log from '~/utils/log';
|
||||
import { users } from '../db/schema';
|
||||
import { Capabilities, Roles } from './roles';
|
||||
|
||||
export interface AuthSession {
|
||||
state: 'auth';
|
||||
api_key: string;
|
||||
user: {
|
||||
subject: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface JWTSession {
|
||||
api_key: string;
|
||||
user: {
|
||||
subject: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OidcFlowSession {
|
||||
state: 'flow';
|
||||
oidc: {
|
||||
state: string;
|
||||
nonce: string;
|
||||
code_verifier: string;
|
||||
redirect_uri: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AuthSessionOptions {
|
||||
secret: string;
|
||||
db: LibSQLDatabase;
|
||||
oidcUsersFile?: string;
|
||||
cookie: {
|
||||
name: string;
|
||||
secure: boolean;
|
||||
maxAge: number;
|
||||
domain?: string;
|
||||
};
|
||||
}
|
||||
|
||||
class Sessionizer {
|
||||
private options: AuthSessionOptions;
|
||||
|
||||
constructor(options: AuthSessionOptions) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
// This throws on the assumption that auth is already checked correctly
|
||||
// on something that wraps the route calling auth. The top-level routes
|
||||
// that call this are wrapped with try/catch to handle the error.
|
||||
async auth(request: Request) {
|
||||
return decodeSession(request, this.options);
|
||||
}
|
||||
|
||||
async createSession(
|
||||
payload: JWTSession,
|
||||
maxAge = this.options.cookie.maxAge,
|
||||
) {
|
||||
// TODO: What the hell is this garbage
|
||||
return createSession(payload, {
|
||||
...this.options,
|
||||
cookie: {
|
||||
...this.options.cookie,
|
||||
maxAge,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async destroySession() {
|
||||
return destroySession(this.options);
|
||||
}
|
||||
|
||||
async roleForSubject(
|
||||
subject: string,
|
||||
): Promise<keyof typeof Roles | undefined> {
|
||||
const [user] = await this.options.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We need this in string form based on Object.keys of the roles
|
||||
for (const [key, value] of Object.entries(Roles)) {
|
||||
if (value === user.caps) {
|
||||
return key as keyof typeof Roles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Given an OR of capabilities, check if the session has the required
|
||||
// capabilities. If not, return false. Can throw since it calls auth()
|
||||
async check(request: Request, capabilities: Capabilities) {
|
||||
const session = await this.auth(request);
|
||||
|
||||
// This is the subject we set on API key based sessions. API keys
|
||||
// inherently imply admin access so we return true for all checks.
|
||||
if (session.user.subject === 'unknown-non-oauth') {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [user] = await this.options.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.sub, session.user.subject))
|
||||
.limit(1);
|
||||
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (capabilities & user.caps) === capabilities;
|
||||
}
|
||||
|
||||
// Updates the capabilities and roles of a subject
|
||||
async reassignSubject(subject: string, role: keyof typeof Roles) {
|
||||
// Check if we are owner
|
||||
const subjectRole = await this.roleForSubject(subject);
|
||||
if (subjectRole === 'owner') {
|
||||
return false;
|
||||
}
|
||||
|
||||
await this.options.db
|
||||
.update(users)
|
||||
.set({
|
||||
caps: Roles[role],
|
||||
})
|
||||
.where(eq(users.sub, subject));
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
async function createSession(payload: JWTSession, options: AuthSessionOptions) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
|
||||
const jwt = await new EncryptJWT({
|
||||
...payload,
|
||||
})
|
||||
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' })
|
||||
.setIssuedAt()
|
||||
.setExpirationTime(now + options.cookie.maxAge)
|
||||
.setIssuer('urn:tale:headplane')
|
||||
.setAudience('urn:tale:headplane')
|
||||
.setJti(ulid())
|
||||
.encrypt(secret);
|
||||
|
||||
const cookie = createCookie(options.cookie.name, {
|
||||
...options.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
return cookie.serialize(jwt);
|
||||
}
|
||||
|
||||
async function decodeSession(request: Request, options: AuthSessionOptions) {
|
||||
const cookieHeader = request.headers.get('cookie');
|
||||
if (cookieHeader === null) {
|
||||
throw new Error('No session cookie found');
|
||||
}
|
||||
|
||||
const cookie = createCookie(options.cookie.name, {
|
||||
...options.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
|
||||
if (cookieValue === null) {
|
||||
throw new Error('Session cookie is empty');
|
||||
}
|
||||
|
||||
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
|
||||
const { payload } = await jwtDecrypt(cookieValue, secret, {
|
||||
issuer: 'urn:tale:headplane',
|
||||
audience: 'urn:tale:headplane',
|
||||
});
|
||||
|
||||
// Safe since we encode the session directly into the JWT
|
||||
return payload as unknown as JWTSession;
|
||||
}
|
||||
|
||||
async function destroySession(options: AuthSessionOptions) {
|
||||
const cookie = createCookie(options.cookie.name, {
|
||||
...options.cookie,
|
||||
path: __PREFIX__,
|
||||
});
|
||||
|
||||
return cookie.serialize('', {
|
||||
expires: new Date(0),
|
||||
});
|
||||
}
|
||||
|
||||
export async function createSessionStorage(options: AuthSessionOptions) {
|
||||
if (options.oidcUsersFile) {
|
||||
await migrateUserDatabase(options.oidcUsersFile, options.db);
|
||||
}
|
||||
|
||||
return new Sessionizer(options);
|
||||
}
|
||||
|
||||
async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
|
||||
const realPath = resolve(path);
|
||||
|
||||
try {
|
||||
const handle = await open(realPath, 'a+');
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
if (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
error.code === 'ENOENT'
|
||||
) {
|
||||
log.debug('config', 'No old user database file found at %s', realPath);
|
||||
return;
|
||||
}
|
||||
|
||||
log.warn('config', 'Failed to migrate old user database at %s', realPath);
|
||||
log.warn(
|
||||
'config',
|
||||
'This is not an error, but existing users will not be migrated',
|
||||
);
|
||||
log.warn('config', 'Unable to open user database file: %s', String(error));
|
||||
log.debug('config', 'Error details: %s', error);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('config', 'Found old user database file at %s', realPath);
|
||||
log.info('config', 'Migrating user database to the new SQL database');
|
||||
|
||||
let migratableUsers: {
|
||||
u: string;
|
||||
c: number;
|
||||
oo?: boolean;
|
||||
}[];
|
||||
|
||||
try {
|
||||
const data = await readFile(realPath, 'utf8');
|
||||
if (data.trim().length === 0) {
|
||||
log.info('config', 'Old user database file is empty, nothing to migrate');
|
||||
log.info(
|
||||
'config',
|
||||
'You SHOULD remove oidc.user_storage_file from your config!',
|
||||
);
|
||||
await rm(realPath, { force: true });
|
||||
return;
|
||||
}
|
||||
|
||||
const users = JSON.parse(data.trim()) as {
|
||||
u?: string;
|
||||
c?: number;
|
||||
oo?: boolean;
|
||||
}[];
|
||||
|
||||
migratableUsers = users.filter(
|
||||
(user) => user.u !== undefined && user.c !== undefined,
|
||||
) as {
|
||||
u: string;
|
||||
c: number;
|
||||
oo?: boolean;
|
||||
}[];
|
||||
} catch (error) {
|
||||
log.warn('config', 'Error reading old user database file: %s', error);
|
||||
log.warn('config', 'Not migrating any users');
|
||||
return;
|
||||
}
|
||||
|
||||
if (migratableUsers.length === 0) {
|
||||
log.info('config', 'No users found in the old database to migrate');
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(
|
||||
'config',
|
||||
'Migrating %d users from the old database',
|
||||
migratableUsers.length,
|
||||
);
|
||||
|
||||
const updated = await db
|
||||
.insert(users)
|
||||
.values(
|
||||
migratableUsers.map((user) => ({
|
||||
id: ulid(),
|
||||
sub: user.u,
|
||||
caps: user.c,
|
||||
onboarded: user.oo ?? false,
|
||||
})),
|
||||
)
|
||||
.onConflictDoNothing({
|
||||
target: users.sub,
|
||||
})
|
||||
.returning();
|
||||
|
||||
log.info('config', 'Migrated %d users successfully', updated.length);
|
||||
log.info('config', 'Removed old user database file %s', realPath);
|
||||
await rm(realPath, { force: true });
|
||||
}
|
||||
+71
-75
@@ -4,113 +4,109 @@
|
||||
@plugin "tailwindcss-react-aria-components";
|
||||
|
||||
@theme {
|
||||
--blur-xs: 2px;
|
||||
--blur-xs: 2px;
|
||||
|
||||
--height-editor: calc(100vh - 20rem);
|
||||
--height-editor: calc(100vh - 20rem);
|
||||
|
||||
--font-sans: Inter, -apple-system, BlinkMacSystemFont, Helvetica, Arial,
|
||||
sans-serif;
|
||||
--font-sans: Inter, -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
|
||||
|
||||
--transition-duration-25: 25ms;
|
||||
--transition-duration-50: 50ms;
|
||||
--transition-duration-25: 25ms;
|
||||
--transition-duration-50: 50ms;
|
||||
|
||||
--color-main-50: #f8fafc;
|
||||
--color-main-100: #f1f5f9;
|
||||
--color-main-200: #e2e8f0;
|
||||
--color-main-300: #cbd5e1;
|
||||
--color-main-400: #94a3b8;
|
||||
--color-main-500: #64748b;
|
||||
--color-main-600: #475569;
|
||||
--color-main-700: #334155;
|
||||
--color-main-800: #1e293b;
|
||||
--color-main-900: #0f172a;
|
||||
--color-main-950: #020617;
|
||||
--color-main-50: #f8fafc;
|
||||
--color-main-100: #f1f5f9;
|
||||
--color-main-200: #e2e8f0;
|
||||
--color-main-300: #cbd5e1;
|
||||
--color-main-400: #94a3b8;
|
||||
--color-main-500: #64748b;
|
||||
--color-main-600: #475569;
|
||||
--color-main-700: #334155;
|
||||
--color-main-800: #1e293b;
|
||||
--color-main-900: #0f172a;
|
||||
--color-main-950: #020617;
|
||||
|
||||
--color-ui-50: #fafafa;
|
||||
--color-ui-100: #f5f5f5;
|
||||
--color-ui-200: #e5e5e5;
|
||||
--color-ui-300: #d4d4d4;
|
||||
--color-ui-400: #a3a3a3;
|
||||
--color-ui-500: #737373;
|
||||
--color-ui-600: #525252;
|
||||
--color-ui-700: #404040;
|
||||
--color-ui-800: #262626;
|
||||
--color-ui-900: #171717;
|
||||
--color-ui-950: #0a0a0a;
|
||||
--color-ui-50: #fafafa;
|
||||
--color-ui-100: #f5f5f5;
|
||||
--color-ui-200: #e5e5e5;
|
||||
--color-ui-300: #d4d4d4;
|
||||
--color-ui-400: #a3a3a3;
|
||||
--color-ui-500: #737373;
|
||||
--color-ui-600: #525252;
|
||||
--color-ui-700: #404040;
|
||||
--color-ui-800: #262626;
|
||||
--color-ui-900: #171717;
|
||||
--color-ui-950: #0a0a0a;
|
||||
|
||||
--color-headplane-50: #f2f2f2;
|
||||
--color-headplane-100: #e6e6e6;
|
||||
--color-headplane-200: #cccccc;
|
||||
--color-headplane-300: #b3b3b3;
|
||||
--color-headplane-400: #999999;
|
||||
--color-headplane-500: #808080;
|
||||
--color-headplane-600: #666666;
|
||||
--color-headplane-700: #4d4d4d;
|
||||
--color-headplane-800: #343434;
|
||||
--color-headplane-900: #1a1a1a;
|
||||
--color-headplane-950: #0d0d0d;
|
||||
--color-headplane-50: #f2f2f2;
|
||||
--color-headplane-100: #e6e6e6;
|
||||
--color-headplane-200: #cccccc;
|
||||
--color-headplane-300: #b3b3b3;
|
||||
--color-headplane-400: #999999;
|
||||
--color-headplane-500: #808080;
|
||||
--color-headplane-600: #666666;
|
||||
--color-headplane-700: #4d4d4d;
|
||||
--color-headplane-800: #343434;
|
||||
--color-headplane-900: #1a1a1a;
|
||||
--color-headplane-950: #0d0d0d;
|
||||
|
||||
--animate-loading: loader 0.8s infinite ease-in-out;
|
||||
--animate-loading: loader 0.8s infinite ease-in-out;
|
||||
|
||||
@keyframes loader {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
@keyframes loader {
|
||||
from {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@utility container {
|
||||
margin-inline: auto;
|
||||
padding-inline: 1rem;
|
||||
margin-inline: auto;
|
||||
padding-inline: 1rem;
|
||||
width: 100%;
|
||||
max-width: 96rem; /* 1536px - constrain on large displays */
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
|
||||
@media (width >= theme(--breakpoint-sm)) {
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
@media (width >= theme(--breakpoint-sm)) {
|
||||
padding-inline: 2rem;
|
||||
}
|
||||
@media (width >= theme(--breakpoint-lg)) {
|
||||
padding-inline: 4rem;
|
||||
}
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
|
||||
@media (width >= theme(--breakpoint-lg)) {
|
||||
padding-inline: 4rem;
|
||||
}
|
||||
@media (width >= theme(--breakpoint-xl)) {
|
||||
padding-inline: 5rem;
|
||||
}
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
|
||||
@media (width >= theme(--breakpoint-xl)) {
|
||||
padding-inline: 5rem;
|
||||
}
|
||||
|
||||
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
|
||||
@media (width >= theme(--breakpoint-2xl)) {
|
||||
padding-inline: 6rem;
|
||||
}
|
||||
@media (width >= theme(--breakpoint-2xl)) {
|
||||
padding-inline: 6rem;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (scrollbar-gutter: stable) {
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
html {
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
}
|
||||
|
||||
.cm-merge-theme {
|
||||
height: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-mergeView {
|
||||
height: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
.cm-mergeViewEditors {
|
||||
height: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.cm-mergeViewEditor {
|
||||
height: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
/* Weirdest class name characters but ok */
|
||||
.cm-mergeView .ͼ1 .cm-scroller,
|
||||
.cm-mergeView .ͼ1 {
|
||||
height: 100% !important;
|
||||
height: 100% !important;
|
||||
}
|
||||
|
||||
+26
-27
@@ -1,35 +1,34 @@
|
||||
import type { PreAuthKey } from './PreAuthKey';
|
||||
import type { User } from './User';
|
||||
import type { PreAuthKey } from "./PreAuthKey";
|
||||
import type { User } from "./User";
|
||||
|
||||
export interface Machine {
|
||||
id: string;
|
||||
machineKey: string;
|
||||
nodeKey: string;
|
||||
discoKey: string;
|
||||
ipAddresses: string[];
|
||||
name: string;
|
||||
id: string;
|
||||
machineKey: string;
|
||||
nodeKey: string;
|
||||
discoKey: string;
|
||||
ipAddresses: string[];
|
||||
name: string;
|
||||
|
||||
user: User;
|
||||
lastSeen: string;
|
||||
expiry: string | null;
|
||||
// User can be null for tag-only nodes in Headscale 0.28+
|
||||
user?: User;
|
||||
lastSeen: string;
|
||||
expiry: string | null;
|
||||
|
||||
preAuthKey?: PreAuthKey;
|
||||
preAuthKey?: PreAuthKey;
|
||||
|
||||
createdAt: string;
|
||||
registerMethod:
|
||||
| 'REGISTER_METHOD_UNSPECIFIED'
|
||||
| 'REGISTER_METHOD_AUTH_KEY'
|
||||
| 'REGISTER_METHOD_CLI'
|
||||
| 'REGISTER_METHOD_OIDC';
|
||||
createdAt: string;
|
||||
registerMethod:
|
||||
| "REGISTER_METHOD_UNSPECIFIED"
|
||||
| "REGISTER_METHOD_AUTH_KEY"
|
||||
| "REGISTER_METHOD_CLI"
|
||||
| "REGISTER_METHOD_OIDC";
|
||||
|
||||
forcedTags: string[];
|
||||
invalidTags: string[];
|
||||
validTags: string[];
|
||||
givenName: string;
|
||||
online: boolean;
|
||||
tags: string[];
|
||||
givenName: string;
|
||||
online: boolean;
|
||||
|
||||
// Added in Headscale 0.26+
|
||||
approvedRoutes: string[];
|
||||
availableRoutes: string[];
|
||||
subnetRoutes: string[];
|
||||
// Added in Headscale 0.26+
|
||||
approvedRoutes: string[];
|
||||
availableRoutes: string[];
|
||||
subnetRoutes: string[];
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { User } from './User';
|
||||
export interface PreAuthKey {
|
||||
id: string;
|
||||
key: string;
|
||||
user: User;
|
||||
user: User | null;
|
||||
reusable: boolean;
|
||||
ephemeral: boolean;
|
||||
used: boolean;
|
||||
|
||||
+39
-54
@@ -1,65 +1,50 @@
|
||||
import { HostInfo, Machine } from '~/types';
|
||||
import { HostInfo, Machine } from "~/types";
|
||||
|
||||
export interface PopulatedNode extends Machine {
|
||||
routes: string[];
|
||||
hostInfo?: HostInfo;
|
||||
expired: boolean;
|
||||
customRouting: {
|
||||
exitRoutes: string[];
|
||||
exitApproved: boolean;
|
||||
subnetApprovedRoutes: string[];
|
||||
subnetWaitingRoutes: string[];
|
||||
};
|
||||
routes: string[];
|
||||
hostInfo?: HostInfo;
|
||||
expired: boolean;
|
||||
customRouting: {
|
||||
exitRoutes: string[];
|
||||
exitApproved: boolean;
|
||||
subnetApprovedRoutes: string[];
|
||||
subnetWaitingRoutes: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export function mapNodes(
|
||||
nodes: Machine[],
|
||||
stats?: Record<string, HostInfo> | undefined,
|
||||
nodes: Machine[],
|
||||
stats?: Record<string, HostInfo> | undefined,
|
||||
): PopulatedNode[] {
|
||||
return nodes.map((node) => {
|
||||
const customRouting = {
|
||||
exitRoutes: node.availableRoutes.filter(
|
||||
(route) => route === '::/0' || route === '0.0.0.0/0',
|
||||
),
|
||||
exitApproved: node.approvedRoutes.some(
|
||||
(route) => route === '::/0' || route === '0.0.0.0/0',
|
||||
),
|
||||
subnetApprovedRoutes: node.approvedRoutes.filter(
|
||||
(route) =>
|
||||
route !== '::/0' &&
|
||||
route !== '0.0.0.0/0' &&
|
||||
node.availableRoutes.includes(route),
|
||||
),
|
||||
subnetWaitingRoutes: node.availableRoutes.filter(
|
||||
(route) =>
|
||||
route !== '::/0' &&
|
||||
route !== '0.0.0.0/0' &&
|
||||
!node.approvedRoutes.includes(route),
|
||||
),
|
||||
} satisfies PopulatedNode['customRouting'];
|
||||
return nodes.map((node) => {
|
||||
const customRouting = {
|
||||
exitRoutes: node.availableRoutes.filter((route) => route === "::/0" || route === "0.0.0.0/0"),
|
||||
exitApproved: node.approvedRoutes.some((route) => route === "::/0" || route === "0.0.0.0/0"),
|
||||
subnetApprovedRoutes: node.approvedRoutes.filter(
|
||||
(route) =>
|
||||
route !== "::/0" && route !== "0.0.0.0/0" && node.availableRoutes.includes(route),
|
||||
),
|
||||
subnetWaitingRoutes: node.availableRoutes.filter(
|
||||
(route) =>
|
||||
route !== "::/0" && route !== "0.0.0.0/0" && !node.approvedRoutes.includes(route),
|
||||
),
|
||||
} satisfies PopulatedNode["customRouting"];
|
||||
|
||||
return {
|
||||
...node,
|
||||
routes: Array.from(new Set(node.availableRoutes)),
|
||||
hostInfo: stats?.[node.nodeKey],
|
||||
customRouting,
|
||||
expired:
|
||||
node.expiry === '0001-01-01 00:00:00' ||
|
||||
node.expiry === '0001-01-01T00:00:00Z' ||
|
||||
node.expiry === null
|
||||
? false
|
||||
: new Date(node.expiry).getTime() < Date.now(),
|
||||
};
|
||||
});
|
||||
return {
|
||||
...node,
|
||||
routes: Array.from(new Set(node.availableRoutes)),
|
||||
hostInfo: stats?.[node.nodeKey],
|
||||
customRouting,
|
||||
expired:
|
||||
node.expiry === "0001-01-01 00:00:00" ||
|
||||
node.expiry === "0001-01-01T00:00:00Z" ||
|
||||
node.expiry === null
|
||||
? false
|
||||
: new Date(node.expiry).getTime() < Date.now(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function sortNodeTags(nodes: Machine[]): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
nodes.flatMap(({ validTags, forcedTags }) => [
|
||||
...validTags,
|
||||
...forcedTags,
|
||||
]),
|
||||
),
|
||||
).sort();
|
||||
return Array.from(new Set(nodes.flatMap((node) => node.tags))).sort();
|
||||
}
|
||||
|
||||
+39
-40
@@ -1,49 +1,48 @@
|
||||
import { createCookie } from 'react-router';
|
||||
import type { HeadplaneConfig } from '~/server/config/config-schema';
|
||||
import { createCookie } from "react-router";
|
||||
|
||||
import type { HeadplaneConfig } from "~/server/config/config-schema";
|
||||
|
||||
export interface OidcStateCookie {
|
||||
nonce: string;
|
||||
state: string;
|
||||
verifier: string;
|
||||
redirect_uri: string;
|
||||
nonce: string;
|
||||
state: string;
|
||||
verifier: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
|
||||
export function createOidcStateCookie(config: HeadplaneConfig) {
|
||||
const cookie = createCookie('__oidc_state', {
|
||||
httpOnly: true,
|
||||
maxAge: 1800,
|
||||
secure: config.server.cookie_secure,
|
||||
domain: config.server.cookie_domain,
|
||||
path: `${__PREFIX__}/oidc/callback`,
|
||||
});
|
||||
const cookie = createCookie("__oidc_state", {
|
||||
httpOnly: true,
|
||||
maxAge: 1800,
|
||||
secure: config.server.cookie_secure,
|
||||
domain: config.server.cookie_domain,
|
||||
path: `${__PREFIX__}/oidc/callback`,
|
||||
});
|
||||
|
||||
return {
|
||||
...cookie,
|
||||
serialize: async (value: OidcStateCookie): Promise<string> => {
|
||||
return cookie.serialize(value);
|
||||
},
|
||||
return {
|
||||
...cookie,
|
||||
serialize: async (value: OidcStateCookie): Promise<string> => {
|
||||
return cookie.serialize(value);
|
||||
},
|
||||
|
||||
parse: async (
|
||||
cookieHeader: string | null,
|
||||
): Promise<OidcStateCookie | null> => {
|
||||
const parsed = await cookie.parse(cookieHeader);
|
||||
if (
|
||||
parsed == null ||
|
||||
typeof parsed !== 'object' ||
|
||||
typeof parsed.nonce !== 'string' ||
|
||||
typeof parsed.state !== 'string' ||
|
||||
typeof parsed.verifier !== 'string' ||
|
||||
typeof parsed.redirect_uri !== 'string'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
parse: async (cookieHeader: string | null): Promise<OidcStateCookie | null> => {
|
||||
const parsed = await cookie.parse(cookieHeader);
|
||||
if (
|
||||
parsed == null ||
|
||||
typeof parsed !== "object" ||
|
||||
typeof parsed.nonce !== "string" ||
|
||||
typeof parsed.state !== "string" ||
|
||||
typeof parsed.verifier !== "string" ||
|
||||
typeof parsed.redirect_uri !== "string"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
nonce: parsed.nonce,
|
||||
state: parsed.state,
|
||||
verifier: parsed.verifier,
|
||||
redirect_uri: parsed.redirect_uri,
|
||||
};
|
||||
},
|
||||
};
|
||||
return {
|
||||
nonce: parsed.nonce,
|
||||
state: parsed.state,
|
||||
verifier: parsed.verifier,
|
||||
redirect_uri: parsed.redirect_uri,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Formats the time delta since a given date into a human-readable string.
|
||||
* - Under 1 hour: "X minutes ago"
|
||||
* - Under 1 day: "X hours, Y minutes ago"
|
||||
* - Under 1 month: "X days, Y hours ago"
|
||||
* - Over 1 month: "X months, Y days ago"
|
||||
*/
|
||||
export function formatTimeDelta(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
|
||||
const minutes = Math.floor(diffMs / (1000 * 60));
|
||||
const hours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
const months = Math.floor(days / 30);
|
||||
|
||||
if (minutes < 60) {
|
||||
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
if (hours < 24) {
|
||||
const remainingMinutes = minutes % 60;
|
||||
if (remainingMinutes === 0) {
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
return `${hours} hour${hours !== 1 ? 's' : ''}, ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
if (days < 30) {
|
||||
const remainingHours = hours % 24;
|
||||
if (remainingHours === 0) {
|
||||
return `${days} day${days !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
return `${days} day${days !== 1 ? 's' : ''}, ${remainingHours} hour${remainingHours !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
|
||||
const remainingDays = days % 30;
|
||||
if (remainingDays === 0) {
|
||||
return `${months} month${months !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
return `${months} month${months !== 1 ? 's' : ''}, ${remainingDays} day${remainingDays !== 1 ? 's' : ''} ago`;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { User } from "~/types/User";
|
||||
|
||||
export function getUserDisplayName(user: User): string {
|
||||
return user.name || user.displayName || user.email || user.id;
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"includes": ["**", "!app/wasm_exec.js"]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"lineWidth": 80,
|
||||
"lineEnding": "lf"
|
||||
},
|
||||
"assist": {
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on",
|
||||
"useSortedAttributes": "on"
|
||||
}
|
||||
}
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"style": {
|
||||
"useImportType": "off",
|
||||
"noNonNullAssertion": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"useExhaustiveDependencies": "off"
|
||||
},
|
||||
"suspicious": {
|
||||
"noExplicitAny": "warn"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"jsxQuoteStyle": "double"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ BUILD_WASM=0
|
||||
BUILD_APP=0
|
||||
BUILD_AGENT=0
|
||||
BUILD_FAKE_SHELL=0
|
||||
BUILD_HEALTHCHECK=0
|
||||
SKIP_PATH_CHECKS=0
|
||||
SKIP_PNPM_PRUNE=0
|
||||
APP_INSTALL_ONLY=0
|
||||
@@ -23,6 +24,7 @@ APP_INSTALL_ONLY=0
|
||||
WASM_OUTPUT="$PUBLIC_DIR/hp_ssh.wasm"
|
||||
AGENT_OUTPUT="$BUILD_DIR/hp_agent"
|
||||
FAKE_SHELL_OUTPUT="$BUILD_DIR/hp_fake_sh"
|
||||
HEALTHCHECK_OUTPUT="$BUILD_DIR/hp_healthcheck"
|
||||
|
||||
die() { echo "error: $*" >&2; exit 1; }
|
||||
run() { echo ">> $*"; "$@"; }
|
||||
@@ -33,6 +35,7 @@ while [ $# -gt 0 ]; do
|
||||
--app) BUILD_APP=1 ;;
|
||||
--agent) BUILD_AGENT=1 ;;
|
||||
--fake-shell) BUILD_FAKE_SHELL=1 ;;
|
||||
--healthcheck) BUILD_HEALTHCHECK=1 ;;
|
||||
--skip-path-checks) SKIP_PATH_CHECKS=1 ;;
|
||||
--skip-pnpm-prune) SKIP_PNPM_PRUNE=1 ;;
|
||||
--app-install-only) APP_INSTALL_ONLY=1 ;;
|
||||
@@ -55,6 +58,12 @@ while [ $# -gt 0 ]; do
|
||||
FAKE_SHELL_OUTPUT=$1
|
||||
;;
|
||||
|
||||
--healthcheck-output)
|
||||
shift
|
||||
[ $# -gt 0 ] || die "--healthcheck-output requires a path"
|
||||
HEALTHCHECK_OUTPUT=$1
|
||||
;;
|
||||
|
||||
--help)
|
||||
cat <<EOF
|
||||
Usage: $0 [flags]
|
||||
@@ -62,12 +71,14 @@ Usage: $0 [flags]
|
||||
--app build react-router app
|
||||
--agent build tailscale agent
|
||||
--fake-shell build fake shell binary (for Docker)
|
||||
--healthcheck build healthcheck binary
|
||||
--skip-path-checks skip safety checks (ie. checking PATH)
|
||||
--skip-pnpm-prune skip pruning devDependencies from node_modules
|
||||
--app-install-only only install app dependencies, skip build
|
||||
--wasm-output <path> override wasm output path
|
||||
--agent-output <path> override agent output path
|
||||
--fake-shell-output <path> override fake shell output path
|
||||
--healthcheck-output <path> override healthcheck output path
|
||||
EOF
|
||||
exit 0
|
||||
;;
|
||||
@@ -84,6 +95,8 @@ if [ "$BUILD_WASM" -eq 0 ] && [ "$BUILD_APP" -eq 0 ] && \
|
||||
BUILD_WASM=1
|
||||
BUILD_APP=1
|
||||
BUILD_AGENT=1
|
||||
BUILD_HEALTHCHECK=1
|
||||
BUILD_FAKE_SHELL=0
|
||||
fi
|
||||
|
||||
if [ "$SKIP_PATH_CHECKS" -eq 0 ]; then
|
||||
@@ -96,6 +109,7 @@ if [ "$SKIP_PATH_CHECKS" -eq 0 ]; then
|
||||
[ "$BUILD_APP" -eq 1 ] && need_pnpm=1
|
||||
[ "$BUILD_AGENT" -eq 1 ] && need_go=1
|
||||
[ "$BUILD_FAKE_SHELL" -eq 1 ] && need_go=1
|
||||
[ "$BUILD_HEALTHCHECK" -eq 1 ] && need_go=1
|
||||
|
||||
if [ $need_go -eq 1 ]; then
|
||||
echo "==> Checking for Go toolchain"
|
||||
@@ -160,11 +174,16 @@ build_fake_shell() {
|
||||
-o "$FAKE_SHELL_OUTPUT" ./cmd/fake_sh
|
||||
}
|
||||
|
||||
[ "$BUILD_FAKE_SHELL" = 1 ] && build_fake_shell
|
||||
build_healthcheck() {
|
||||
echo "==> Building healthcheck binary → $HEALTHCHECK_OUTPUT"
|
||||
mkdir -p "$(dirname "$HEALTHCHECK_OUTPUT")"
|
||||
go build -o "$HEALTHCHECK_OUTPUT" ./cmd/hp_healthcheck
|
||||
}
|
||||
|
||||
[ "$BUILD_WASM" = 1 ] && build_wasm
|
||||
[ "$BUILD_APP" = 1 ] && build_app
|
||||
[ "$BUILD_AGENT" = 1 ] && build_agent
|
||||
[ "$BUILD_FAKE_SHELL" = 1 ] && build_fake_shell
|
||||
[ "$BUILD_HEALTHCHECK" = 1 ] && build_healthcheck
|
||||
|
||||
echo "✅ Build complete."
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
client := http.Client{
|
||||
Timeout: 5 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Get("http://localhost:3000/admin/healthz")
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Health check failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Fprintf(os.Stderr, "Health check returned non-OK status: %d\n", resp.StatusCode)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("Health check passed.")
|
||||
os.Exit(0)
|
||||
}
|
||||
+1
-1
@@ -24,7 +24,7 @@ services:
|
||||
- "./test/caddy/config:/config"
|
||||
- "./test/caddy/certs:/certs"
|
||||
headscale:
|
||||
image: "headscale/headscale:0.27.0-debug"
|
||||
image: "headscale/headscale:0.28.0"
|
||||
container_name: "headscale"
|
||||
labels:
|
||||
me.tale.headplane.target: headscale
|
||||
|
||||
+63
-44
@@ -35,6 +35,16 @@ server:
|
||||
# PLEASE ensure this directory is mounted if running in Docker.
|
||||
data_path: "/var/lib/headplane"
|
||||
|
||||
# The info secret is optional and allows access to certain debug endpoints
|
||||
# that may expose sensitive information about your Headplane instance.
|
||||
#
|
||||
# As of now, this protects the /api/info endpoint which exposes details about
|
||||
# the Headplane and Headscale versions in use. In the future, more endpoints
|
||||
# may be protected by this secret.
|
||||
#
|
||||
# If not set, these endpoints will be disabled.
|
||||
# info_secret: "<change_me_to_something_secure!>"
|
||||
|
||||
# Headscale specific settings to allow Headplane to talk
|
||||
# to Headscale and access deep integration features
|
||||
headscale:
|
||||
@@ -154,56 +164,65 @@ integration:
|
||||
proc:
|
||||
enabled: false
|
||||
|
||||
# OIDC Configuration for simpler authentication
|
||||
# (This is optional, but recommended for the best experience)
|
||||
# oidc:
|
||||
# The OIDC issuer URL
|
||||
# issuer: "https://accounts.google.com"
|
||||
# OIDC Configuration for simpler authentication
|
||||
# (This is optional, but recommended for the best experience)
|
||||
# oidc:
|
||||
# Set to false to define OIDC config without enabling it.
|
||||
# Useful for Helm charts or generating docs from config files.
|
||||
# enabled: true
|
||||
|
||||
# If you are using OIDC, you need to generate an API key
|
||||
# that can be used to authenticate other sessions when signing in.
|
||||
#
|
||||
# This can be done with `headscale apikeys create --expiration 999d`
|
||||
# headscale_api_key: "<your-headscale-api-key>"
|
||||
# The OIDC issuer URL
|
||||
# issuer: "https://accounts.google.com"
|
||||
|
||||
# If your OIDC provider does not support discovery (does not have the URL at
|
||||
# `/.well-known/openid-configuration`), you need to manually set endpoints.
|
||||
# This also works to override endpoints if you so desire or if your OIDC
|
||||
# discovery is missing certain endpoints (ie GitHub).
|
||||
# For some typical providers, see https://headplane.net/features/sso.
|
||||
# authorization_endpoint: ""
|
||||
# token_endpoint: ""
|
||||
# userinfo_endpoint: ""
|
||||
# If you are using OIDC, you need to generate an API key
|
||||
# that can be used to authenticate other sessions when signing in.
|
||||
#
|
||||
# This can be done with `headscale apikeys create --expiration 999d`
|
||||
# headscale_api_key: "<your-headscale-api-key>"
|
||||
|
||||
# The client ID for the OIDC client
|
||||
# For the best experience please ensure this is *identical* to the client_id
|
||||
# you are using for Headscale. because
|
||||
# client_id: "your-client-id"
|
||||
# If your OIDC provider does not support discovery (does not have the URL at
|
||||
# `/.well-known/openid-configuration`), you need to manually set endpoints.
|
||||
# This also works to override endpoints if you so desire or if your OIDC
|
||||
# discovery is missing certain endpoints (ie GitHub).
|
||||
# For some typical providers, see https://headplane.net/features/sso.
|
||||
# authorization_endpoint: ""
|
||||
# token_endpoint: ""
|
||||
# userinfo_endpoint: ""
|
||||
|
||||
# The client secret for the OIDC client
|
||||
# You may also provide `client_secret_path` instead to read a value from disk.
|
||||
# See https://headplane.net/configuration/#sensitive-values
|
||||
# client_secret: "<your-client-secret>"
|
||||
# The authentication method to use when communicating with the token endpoint.
|
||||
# This is fully optional and Headplane will attempt to auto-detect the best
|
||||
# method and fall back to `client_secret_basic` if unsure.
|
||||
# token_endpoint_auth_method: "client_secret_post"
|
||||
|
||||
# Whether to use PKCE when authenticating users. This is recommended as it
|
||||
# adds an extra layer of security to the authentication process. Enabling this
|
||||
# means your OIDC provider must support PKCE and it must be enabled on the
|
||||
# client.
|
||||
# use_pkce: true
|
||||
# The client ID for the OIDC client
|
||||
# For the best experience please ensure this is *identical* to the client_id
|
||||
# you are using for Headscale. because
|
||||
# client_id: "your-client-id"
|
||||
|
||||
# If you want to disable traditional login via Headscale API keys
|
||||
# disable_api_key_login: false
|
||||
# The client secret for the OIDC client
|
||||
# You may also provide `client_secret_path` instead to read a value from disk.
|
||||
# See https://headplane.net/configuration/#sensitive-values
|
||||
# client_secret: "<your-client-secret>"
|
||||
|
||||
# By default profile pictures are pulled from the OIDC provider when
|
||||
# we go to fetch the userinfo endpoint. Optionally, this can be set to
|
||||
# "oidc" or "gravatar" as of 0.6.1.
|
||||
# profile_picture_source: "gravatar"
|
||||
# Whether to use PKCE when authenticating users. This is recommended as it
|
||||
# adds an extra layer of security to the authentication process. Enabling this
|
||||
# means your OIDC provider must support PKCE and it must be enabled on the
|
||||
# client.
|
||||
# use_pkce: true
|
||||
|
||||
# The scopes to request when authenticating users. The default is below.
|
||||
# scope: "openid email profile"
|
||||
# If you want to disable traditional login via Headscale API keys
|
||||
# disable_api_key_login: false
|
||||
|
||||
# Extra query parameters can be passed to the authorization endpoint
|
||||
# by setting them here. This is useful for providers that require any kind
|
||||
# of custom hinting.
|
||||
# extra_params:
|
||||
# prompt: "select_account" # Example: force account selection on Google
|
||||
# By default profile pictures are pulled from the OIDC provider when
|
||||
# we go to fetch the userinfo endpoint. Optionally, this can be set to
|
||||
# "oidc" or "gravatar" as of 0.6.1.
|
||||
# profile_picture_source: "gravatar"
|
||||
|
||||
# The scopes to request when authenticating users. The default is below.
|
||||
# scope: "openid email profile"
|
||||
|
||||
# Extra query parameters can be passed to the authorization endpoint
|
||||
# by setting them here. This is useful for providers that require any kind
|
||||
# of custom hinting.
|
||||
# extra_params:
|
||||
# prompt: "select_account" # Example: force account selection on Google
|
||||
|
||||
@@ -11,6 +11,9 @@ export default defineConfig({
|
||||
{ text: 'Home', link: '/' },
|
||||
{ text: 'Changelog', link: '/CHANGELOG' },
|
||||
],
|
||||
search: {
|
||||
provider: 'local',
|
||||
},
|
||||
sidebar: [
|
||||
{
|
||||
text: 'Getting Started',
|
||||
@@ -30,6 +33,10 @@ export default defineConfig({
|
||||
link: '/configuration',
|
||||
items: [
|
||||
{ text: 'Common Issues', link: '/configuration/common-issues' },
|
||||
{
|
||||
text: 'Sensitive Values',
|
||||
link: '/configuration#sensitive-values',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ text: 'Nix', link: '/Nix' },
|
||||
|
||||
+24
-20
@@ -6,39 +6,42 @@ sole maintainer, I have a few guidelines which help make it significantly easier
|
||||
for me to review and merge your contributions.
|
||||
|
||||
## Contribution Types
|
||||
|
||||
- **Bug Reports/Feature Requests**: If you find a bug, please open an
|
||||
[issue](https://github.com/tale/headplane/issues) using one of the predefined
|
||||
templates. Issues are only for bug reports and feature requests, not problems
|
||||
that you encounter from misconfiguration or usage. Those belong in the
|
||||
[discussions](https://github.com/tale/headplane/discussions) section.
|
||||
[issue](https://github.com/tale/headplane/issues) using one of the predefined
|
||||
templates. Issues are only for bug reports and feature requests, not problems
|
||||
that you encounter from misconfiguration or usage. Those belong in the
|
||||
[discussions](https://github.com/tale/headplane/discussions) section.
|
||||
|
||||
- **Documentation/Examples**: If you find any issues in the documentation or
|
||||
would like to contribute examples for setting up Headplane, please open a PR
|
||||
and I will review it and possibly make changes.
|
||||
would like to contribute examples for setting up Headplane, please open a PR
|
||||
and I will review it and possibly make changes.
|
||||
|
||||
- **Code Contributions**: Code contributions are done via PRs but *must* be
|
||||
linked to an issue or a feature request (you can make one if it doesn't exist).
|
||||
Please tag them with the appropriate labels when opening an issue or PR.
|
||||
- **Code Contributions**: Code contributions are done via PRs but _must_ be
|
||||
linked to an issue or a feature request (you can make one if it doesn't exist).
|
||||
Please tag them with the appropriate labels when opening an issue or PR.
|
||||
|
||||
### Code Contribution Restrictions
|
||||
|
||||
- **No Large Refactors**: I am not interested in large refactors of the codebase
|
||||
because I've already had do to this a lot and it becomes a headache to review,
|
||||
maintain, and merge. This also means you should split up multiple contributions
|
||||
into multiple PRs if they are unrelated to each other.
|
||||
because I've already had do to this a lot and it becomes a headache to review,
|
||||
maintain, and merge. This also means you should split up multiple contributions
|
||||
into multiple PRs if they are unrelated to each other.
|
||||
|
||||
- **No Project/Tooling Changes**: Unless there is a very good reason to do so,
|
||||
I will not accept changes to the project structure, build system, or tooling
|
||||
used to develop Headplane. This includes things like changing the package
|
||||
manager, docker environment, or CI/CD.
|
||||
I will not accept changes to the project structure, build system, or tooling
|
||||
used to develop Headplane. This includes things like changing the package
|
||||
manager, docker environment, or CI/CD.
|
||||
|
||||
- **Minimal Breaking Changes**: I will not accept any changes that break any
|
||||
existing functionality or change the API unless there is a very good reason
|
||||
to do so. If you want to make a breaking change, please open an issue first
|
||||
and discuss it with me.
|
||||
existing functionality or change the API unless there is a very good reason
|
||||
to do so. If you want to make a breaking change, please open an issue first
|
||||
and discuss it with me.
|
||||
|
||||
### Code Style
|
||||
This is very easy and self-explanatory. [Biome](https://biomejs.dev) is used as
|
||||
a linter and formatter for the TypeScript, while Go's default formatting and
|
||||
|
||||
This is very easy and self-explanatory. [oxlint/oxfmt](https://oxc.rs) is used
|
||||
as a linter and formatter for the TypeScript, while Go's default formatting and
|
||||
lint tooling are used within `agent/` code. I've setup a git hook to run
|
||||
before commit to make these changes automatically.
|
||||
|
||||
@@ -47,6 +50,7 @@ before commit to make these changes automatically.
|
||||
> but instead we can discuss them and see if we can come to a compromise.
|
||||
|
||||
### Contribution Tooling
|
||||
|
||||
If you plan to work with the WASM SSH agent, you will need to install
|
||||
`mkcert` or an equivalent to create certificates in `./test/caddy/certs`.
|
||||
It expects a `localhost.pem` and `localhost-key.pem` file to be present in that
|
||||
|
||||
+137
-165
@@ -5,374 +5,346 @@ All options must be under `services.headplane`.
|
||||
For example: `settings.headscale.config_path` becomes `services.headplane.settings.headscale.config_path`.
|
||||
|
||||
## debug
|
||||
*Description:* Enable debug logging
|
||||
|
||||
*Type:* boolean
|
||||
_Description:_ Enable debug logging
|
||||
|
||||
*Default:* `false`
|
||||
_Type:_ boolean
|
||||
|
||||
_Default:_ `false`
|
||||
|
||||
## enable
|
||||
*Description:* Whether to enable headplane.
|
||||
|
||||
*Type:* boolean
|
||||
_Description:_ Whether to enable headplane.
|
||||
|
||||
*Default:* `false`
|
||||
_Type:_ boolean
|
||||
|
||||
*Example:* `true`
|
||||
_Default:_ `false`
|
||||
|
||||
_Example:_ `true`
|
||||
|
||||
## package
|
||||
*Description:* The headplane package to use.
|
||||
|
||||
*Type:* package
|
||||
_Description:_ The headplane package to use.
|
||||
|
||||
*Default:* `pkgs.headplane`
|
||||
_Type:_ package
|
||||
|
||||
_Default:_ `pkgs.headplane`
|
||||
|
||||
## settings
|
||||
*Description:* Headplane configuration options. Generates a YAML config file.
|
||||
|
||||
_Description:_ Headplane configuration options. Generates a YAML config file.
|
||||
See: https://github.com/tale/headplane/blob/main/config.example.yaml
|
||||
|
||||
_Type:_ submodule
|
||||
|
||||
*Type:* submodule
|
||||
|
||||
*Default:* `{ }`
|
||||
|
||||
_Default:_ `{ }`
|
||||
|
||||
## settings.headscale
|
||||
*Description:* Headscale specific settings for Headplane integration.
|
||||
|
||||
*Type:* submodule
|
||||
_Description:_ Headscale specific settings for Headplane integration.
|
||||
|
||||
*Default:* `{ }`
|
||||
_Type:_ submodule
|
||||
|
||||
_Default:_ `{ }`
|
||||
|
||||
## settings.headscale.config_path
|
||||
*Description:* Path to the Headscale configuration file.
|
||||
|
||||
_Description:_ Path to the Headscale configuration file.
|
||||
This is optional, but HIGHLY recommended for the best experience.
|
||||
If this is read only, Headplane will show your configuration settings
|
||||
in the Web UI, but they cannot be changed.
|
||||
|
||||
_Type:_ null or absolute path
|
||||
|
||||
*Type:* null or absolute path
|
||||
|
||||
*Default:* `null`
|
||||
|
||||
*Example:* `"/etc/headscale/config.yaml"`
|
||||
_Default:_ `null`
|
||||
|
||||
_Example:_ `"/etc/headscale/config.yaml"`
|
||||
|
||||
## settings.headscale.config_strict
|
||||
*Description:* Headplane internally validates the Headscale configuration
|
||||
|
||||
_Description:_ Headplane internally validates the Headscale configuration
|
||||
to ensure that it changes the configuration in a safe way.
|
||||
If you want to disable this validation, set this to false.
|
||||
|
||||
_Type:_ boolean
|
||||
|
||||
*Type:* boolean
|
||||
|
||||
*Default:* `true`
|
||||
|
||||
_Default:_ `true`
|
||||
|
||||
## settings.headscale.dns_records_path
|
||||
*Description:* If you are using `dns.extra_records_path` in your Headscale configuration, you need to set this to the path for Headplane to be able to read the DNS records.
|
||||
|
||||
_Description:_ If you are using `dns.extra_records_path` in your Headscale configuration, you need to set this to the path for Headplane to be able to read the DNS records.
|
||||
Ensure that the file is both readable and writable by the Headplane process.
|
||||
When using this, Headplane will no longer need to automatically restart Headscale for DNS record changes.
|
||||
|
||||
_Type:_ null or absolute path
|
||||
|
||||
*Type:* null or absolute path
|
||||
|
||||
*Default:* `null`
|
||||
|
||||
*Example:* `"/var/lib/headplane/extra_records.json"`
|
||||
_Default:_ `null`
|
||||
|
||||
_Example:_ `"/var/lib/headplane/extra_records.json"`
|
||||
|
||||
## settings.headscale.public_url
|
||||
*Description:* Public URL if differrent. This affects certain parts of the web UI.
|
||||
|
||||
*Type:* null or string
|
||||
_Description:_ Public URL if differrent. This affects certain parts of the web UI.
|
||||
|
||||
*Default:* `null`
|
||||
_Type:_ null or string
|
||||
|
||||
*Example:* `"https://headscale.example.com"`
|
||||
_Default:_ `null`
|
||||
|
||||
_Example:_ `"https://headscale.example.com"`
|
||||
|
||||
## settings.headscale.tls_cert_path
|
||||
*Description:* Path to a file containing the TLS certificate.
|
||||
|
||||
_Description:_ Path to a file containing the TLS certificate.
|
||||
|
||||
*Type:* null or absolute path
|
||||
_Type:_ null or absolute path
|
||||
|
||||
*Default:* `null`
|
||||
|
||||
*Example:* `"config.sops.secrets.tls_cert.path"`
|
||||
_Default:_ `null`
|
||||
|
||||
_Example:_ `"config.sops.secrets.tls_cert.path"`
|
||||
|
||||
## settings.headscale.url
|
||||
*Description:* The URL to your Headscale instance.
|
||||
|
||||
_Description:_ The URL to your Headscale instance.
|
||||
All API requests are routed through this URL.
|
||||
THIS IS NOT the gRPC endpoint, but the HTTP endpoint.
|
||||
IMPORTANT: If you are using TLS this MUST be set to `https://`.
|
||||
|
||||
_Type:_ string
|
||||
|
||||
*Type:* string
|
||||
|
||||
*Default:* `"http://127.0.0.1:8080"`
|
||||
|
||||
*Example:* `"https://headscale.example.com"`
|
||||
_Default:_ `"http://127.0.0.1:8080"`
|
||||
|
||||
_Example:_ `"https://headscale.example.com"`
|
||||
|
||||
## settings.integration
|
||||
*Description:* Integration configurations for Headplane to interact with Headscale.
|
||||
|
||||
*Type:* submodule
|
||||
_Description:_ Integration configurations for Headplane to interact with Headscale.
|
||||
|
||||
*Default:* `{ }`
|
||||
_Type:_ submodule
|
||||
|
||||
_Default:_ `{ }`
|
||||
|
||||
## settings.integration.agent
|
||||
*Description:* Agent configuration for the Headplane agent.
|
||||
|
||||
*Type:* submodule
|
||||
_Description:_ Agent configuration for the Headplane agent.
|
||||
|
||||
*Default:* `{ }`
|
||||
_Type:_ submodule
|
||||
|
||||
_Default:_ `{ }`
|
||||
|
||||
## settings.integration.agent.cache_path
|
||||
*Description:* Where to store the agent cache.
|
||||
|
||||
*Type:* absolute path
|
||||
_Description:_ Where to store the agent cache.
|
||||
|
||||
*Default:* `"/var/lib/headplane/agent_cache.json"`
|
||||
_Type:_ absolute path
|
||||
|
||||
_Default:_ `"/var/lib/headplane/agent_cache.json"`
|
||||
|
||||
## settings.integration.agent.cache_ttl
|
||||
*Description:* How long to cache agent information (in milliseconds).
|
||||
|
||||
_Description:_ How long to cache agent information (in milliseconds).
|
||||
If you want data to update faster, reduce the TTL, but this will increase the frequency of requests to Headscale.
|
||||
|
||||
_Type:_ signed integer
|
||||
|
||||
*Type:* signed integer
|
||||
|
||||
*Default:* `180000`
|
||||
|
||||
_Default:_ `180000`
|
||||
|
||||
## settings.integration.agent.enabled
|
||||
*Description:* The Headplane agent allows retrieving information about nodes.
|
||||
|
||||
_Description:_ The Headplane agent allows retrieving information about nodes.
|
||||
This allows the UI to display version, OS, and connectivity data.
|
||||
You will see the Headplane agent in your Tailnet as a node when it connects.
|
||||
|
||||
_Type:_ boolean
|
||||
|
||||
*Type:* boolean
|
||||
|
||||
*Default:* `false`
|
||||
|
||||
_Default:_ `false`
|
||||
|
||||
## settings.integration.agent.host_name
|
||||
*Description:* Optionally change the name of the agent in the Tailnet
|
||||
|
||||
*Type:* string
|
||||
_Description:_ Optionally change the name of the agent in the Tailnet
|
||||
|
||||
*Default:* `"headplane-agent"`
|
||||
_Type:_ string
|
||||
|
||||
_Default:_ `"headplane-agent"`
|
||||
|
||||
## settings.integration.agent.package
|
||||
*Description:* The headplane-agent package to use.
|
||||
|
||||
*Type:* package
|
||||
_Description:_ The headplane-agent package to use.
|
||||
|
||||
*Default:* `pkgs.headplane-agent`
|
||||
_Type:_ package
|
||||
|
||||
_Default:_ `pkgs.headplane-agent`
|
||||
|
||||
## settings.integration.agent.pre_authkey_path
|
||||
*Description:* Path to a file containing the agent preauth key.
|
||||
|
||||
_Description:_ Path to a file containing the agent preauth key.
|
||||
To connect to your Tailnet, you need to generate a pre-auth key.
|
||||
This can be done via the web UI or through the `headscale` CLI.
|
||||
|
||||
_Type:_ null or absolute path
|
||||
|
||||
*Type:* null or absolute path
|
||||
|
||||
*Default:* `null`
|
||||
|
||||
*Example:* `"config.sops.secrets.agent_pre_authkey.path"`
|
||||
_Default:_ `null`
|
||||
|
||||
_Example:_ `"config.sops.secrets.agent_pre_authkey.path"`
|
||||
|
||||
## settings.integration.agent.work_dir
|
||||
*Description:* Do not change this unless you are running a custom deployment.
|
||||
|
||||
_Description:_ Do not change this unless you are running a custom deployment.
|
||||
The work_dir represents where the agent will store its data to be able to automatically reauthenticate with your Tailnet.
|
||||
It needs to be writable by the user running the Headplane process.
|
||||
|
||||
_Type:_ absolute path
|
||||
|
||||
*Type:* absolute path
|
||||
|
||||
*Default:* `"/var/lib/headplane/agent"`
|
||||
|
||||
_Default:_ `"/var/lib/headplane/agent"`
|
||||
|
||||
## settings.integration.proc
|
||||
*Description:* Native process integration settings.
|
||||
|
||||
*Type:* submodule
|
||||
_Description:_ Native process integration settings.
|
||||
|
||||
*Default:* `{ }`
|
||||
_Type:_ submodule
|
||||
|
||||
_Default:_ `{ }`
|
||||
|
||||
## settings.integration.proc.enabled
|
||||
*Description:* Enable "Native" integration that works when Headscale and
|
||||
|
||||
_Description:_ Enable "Native" integration that works when Headscale and
|
||||
Headplane are running outside of a container. There is no additional
|
||||
configuration, but you need to ensure that the Headplane process
|
||||
can terminate the Headscale process.
|
||||
|
||||
_Type:_ boolean
|
||||
|
||||
*Type:* boolean
|
||||
|
||||
*Default:* `true`
|
||||
|
||||
_Default:_ `true`
|
||||
|
||||
## settings.oidc
|
||||
*Description:* OIDC Configuration for authentication.
|
||||
|
||||
*Type:* submodule
|
||||
_Description:_ OIDC Configuration for authentication.
|
||||
|
||||
*Default:* `{ }`
|
||||
_Type:_ submodule
|
||||
|
||||
_Default:_ `{ }`
|
||||
|
||||
## settings.oidc.client_id
|
||||
*Description:* The client ID for the OIDC client.
|
||||
|
||||
*Type:* string
|
||||
_Description:_ The client ID for the OIDC client.
|
||||
|
||||
*Default:* `""`
|
||||
_Type:_ string
|
||||
|
||||
*Example:* `"your-client-id"`
|
||||
_Default:_ `""`
|
||||
|
||||
_Example:_ `"your-client-id"`
|
||||
|
||||
## settings.oidc.client_secret_path
|
||||
*Description:* Path to a file containing the OIDC client secret.
|
||||
|
||||
_Description:_ Path to a file containing the OIDC client secret.
|
||||
|
||||
*Type:* null or absolute path
|
||||
_Type:_ null or absolute path
|
||||
|
||||
*Default:* `null`
|
||||
|
||||
*Example:* `"config.sops.secrets.oidc_client_secret.path"`
|
||||
_Default:_ `null`
|
||||
|
||||
_Example:_ `"config.sops.secrets.oidc_client_secret.path"`
|
||||
|
||||
## settings.oidc.disable_api_key_login
|
||||
*Description:* Whether to disable API key login.
|
||||
|
||||
*Type:* boolean
|
||||
_Description:_ Whether to disable API key login.
|
||||
|
||||
*Default:* `false`
|
||||
_Type:_ boolean
|
||||
|
||||
_Default:_ `false`
|
||||
|
||||
## settings.oidc.headscale_api_key_path
|
||||
*Description:* Path to a file containing the Headscale API key.
|
||||
|
||||
_Description:_ Path to a file containing the Headscale API key.
|
||||
|
||||
*Type:* null or absolute path
|
||||
_Type:_ null or absolute path
|
||||
|
||||
*Default:* `null`
|
||||
|
||||
*Example:* `"config.sops.secrets.headscale_api_key.path"`
|
||||
_Default:_ `null`
|
||||
|
||||
_Example:_ `"config.sops.secrets.headscale_api_key.path"`
|
||||
|
||||
## settings.oidc.issuer
|
||||
*Description:* URL to OpenID issuer.
|
||||
|
||||
*Type:* string
|
||||
_Description:_ URL to OpenID issuer.
|
||||
|
||||
*Default:* `""`
|
||||
_Type:_ string
|
||||
|
||||
*Example:* `"https://provider.example.com/issuer-url"`
|
||||
_Default:_ `""`
|
||||
|
||||
_Example:_ `"https://provider.example.com/issuer-url"`
|
||||
|
||||
## settings.oidc.redirect_uri
|
||||
*Description:* This should point to your publicly accessible URL
|
||||
|
||||
_Description:_ This should point to your publicly accessible URL
|
||||
for your Headplane instance with /admin/oidc/callback.
|
||||
|
||||
_Type:_ string
|
||||
|
||||
*Type:* string
|
||||
|
||||
*Default:* `""`
|
||||
|
||||
*Example:* `"https://headscale.example.com/admin/oidc/callback"`
|
||||
_Default:_ `""`
|
||||
|
||||
_Example:_ `"https://headscale.example.com/admin/oidc/callback"`
|
||||
|
||||
## settings.oidc.token_endpoint_auth_method
|
||||
*Description:* The token endpoint authentication method.
|
||||
|
||||
*Type:* one of "client_secret_post", "client_secret_basic", "client_secret_jwt"
|
||||
_Description:_ The token endpoint authentication method.
|
||||
|
||||
*Default:* `"client_secret_post"`
|
||||
|
||||
|
||||
## settings.oidc.user_storage_file
|
||||
*Description:* Path to a file containing the users and their permissions for Headplane.
|
||||
|
||||
|
||||
*Type:* absolute path
|
||||
|
||||
*Default:* `"/var/lib/headplane/users.json"`
|
||||
|
||||
*Example:* `"/var/lib/headplane/users.json"`
|
||||
_Type:_ one of "client_secret_post", "client_secret_basic", "client_secret_jwt"
|
||||
|
||||
_Default:_ `"client_secret_post"`
|
||||
|
||||
## settings.server
|
||||
*Description:* Server configuration for Headplane web application.
|
||||
|
||||
*Type:* submodule
|
||||
_Description:_ Server configuration for Headplane web application.
|
||||
|
||||
*Default:* `{ }`
|
||||
_Type:_ submodule
|
||||
|
||||
_Default:_ `{ }`
|
||||
|
||||
## settings.server.cookie_secret_path
|
||||
*Description:* Path to a file containing the cookie secret.
|
||||
|
||||
_Description:_ Path to a file containing the cookie secret.
|
||||
The secret must be exactly 32 characters long.
|
||||
|
||||
_Type:_ null or absolute path
|
||||
|
||||
*Type:* null or absolute path
|
||||
|
||||
*Default:* `null`
|
||||
|
||||
*Example:* `"config.sops.secrets.headplane_cookie.path"`
|
||||
_Default:_ `null`
|
||||
|
||||
_Example:_ `"config.sops.secrets.headplane_cookie.path"`
|
||||
|
||||
## settings.server.cookie_secure
|
||||
*Description:* Should the cookies only work over HTTPS?
|
||||
|
||||
_Description:_ Should the cookies only work over HTTPS?
|
||||
Set to false if running via HTTP without a proxy.
|
||||
Recommended to be true in production.
|
||||
|
||||
_Type:_ boolean
|
||||
|
||||
*Type:* boolean
|
||||
|
||||
*Default:* `true`
|
||||
|
||||
_Default:_ `true`
|
||||
|
||||
## settings.server.data_path
|
||||
*Description:* The path to persist Headplane specific data.
|
||||
|
||||
_Description:_ The path to persist Headplane specific data.
|
||||
All data going forward is stored in this directory, including the internal database and any cache related files.
|
||||
Data formats prior to 0.6.1 will automatically be migrated.
|
||||
|
||||
_Type:_ absolute path
|
||||
|
||||
*Type:* absolute path
|
||||
|
||||
*Default:* `"/var/lib/headplane"`
|
||||
|
||||
*Example:* `"/var/lib/headplane"`
|
||||
_Default:_ `"/var/lib/headplane"`
|
||||
|
||||
_Example:_ `"/var/lib/headplane"`
|
||||
|
||||
## settings.server.host
|
||||
*Description:* The host address to bind to.
|
||||
|
||||
*Type:* string
|
||||
_Description:_ The host address to bind to.
|
||||
|
||||
*Default:* `"127.0.0.1"`
|
||||
_Type:_ string
|
||||
|
||||
*Example:* `"0.0.0.0"`
|
||||
_Default:_ `"127.0.0.1"`
|
||||
|
||||
_Example:_ `"0.0.0.0"`
|
||||
|
||||
## settings.server.port
|
||||
*Description:* The port to listen on.
|
||||
|
||||
*Type:* 16 bit unsigned integer; between 0 and 65535 (both inclusive)
|
||||
_Description:_ The port to listen on.
|
||||
|
||||
*Default:* `3000`
|
||||
_Type:_ 16 bit unsigned integer; between 0 and 65535 (both inclusive)
|
||||
|
||||
_Default:_ `3000`
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user