Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbdb759a7e | ||
|
|
6716e5f0b0 | ||
|
|
346b44ec69 | ||
|
|
faa61b0f1d | ||
|
|
6b63fe209f | ||
|
|
c8507cff7c | ||
|
|
b86f4461c0 | ||
|
|
77b510c927 | ||
|
|
5adcb8c582 | ||
|
|
524c5eb639 | ||
|
|
2894c664d3 | ||
|
|
5c2d08decd | ||
|
|
f2e8c6ae4c | ||
|
|
c3e727842a | ||
|
|
96345ab0a6 | ||
|
|
fe2d7cb57a | ||
|
|
66c7d9a327 | ||
|
|
f2747ada94 | ||
|
|
0ad578e651 | ||
|
|
8d1132606a | ||
|
|
5e332c4a5c | ||
|
|
69c6fc4847 | ||
|
|
9b09b13b5f | ||
|
|
cf90f3dd32 | ||
|
|
72c1174cb3 | ||
|
|
63bfad77ce | ||
|
|
41223b89b3 | ||
|
|
93d5c2ed29 | ||
|
|
6a94e815f2 | ||
|
|
234020eec5 | ||
|
|
58cc7b742c | ||
|
|
259d150fc4 | ||
|
|
5d3fada266 | ||
|
|
9d046a0cf6 | ||
|
|
1fb084451d | ||
|
|
d5fb8a2966 | ||
|
|
7b1340c93e | ||
|
|
16a8122f85 | ||
|
|
7d61ad50c4 | ||
|
|
103a826178 | ||
|
|
090dec1ca6 | ||
|
|
8596a56375 | ||
|
|
000ec620b4 | ||
|
|
83a69792ea | ||
|
|
fee5f423a6 | ||
|
|
d698cf5478 | ||
|
|
80c987f383 | ||
|
|
17d477bf0f | ||
|
|
5e5c7c4c7a | ||
|
|
2e383ddabe | ||
|
|
2299907932 | ||
|
|
3771890f98 | ||
|
|
bf02015dc7 | ||
|
|
8429b19c4a | ||
|
|
9a5952adcb | ||
|
|
222ac7a279 | ||
|
|
6f40f9cfac | ||
|
|
457cbc45e6 | ||
|
|
aac8a9ef12 | ||
|
|
b8d22beb17 | ||
|
|
cac64a6fbe | ||
|
|
5918d0e501 | ||
|
|
03acebb23e | ||
|
|
73ea35980d | ||
|
|
9a1051b9af | ||
|
|
c066b3064d | ||
|
|
98d02bb595 | ||
|
|
2964ff295e | ||
|
|
b0a3f9d5fd | ||
|
|
34cfee7cff | ||
|
|
8db323b63f | ||
|
|
08c25caca3 | ||
|
|
cbbd64e91a | ||
|
|
48fc0f7ef3 | ||
|
|
23fd2bbda2 | ||
|
|
05837963c4 | ||
|
|
2a16115e69 | ||
|
|
5675ecdeac | ||
|
|
29424366a8 | ||
|
|
c47346df52 | ||
|
|
92dedf51aa |
@ -1,7 +0,0 @@
|
||||
ROOT_API_KEY=abcdefghijklmnopqrstuvwxyz
|
||||
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
|
||||
DISABLE_API_KEY_LOGIN=true
|
||||
HEADSCALE_CONTAINER=headscale
|
||||
HOST=0.0.0.0
|
||||
PORT=3000
|
||||
CONFIG_FILE=/etc/headscale/config.yaml
|
||||
2
.github/FUNDING.yml
vendored
Normal file
2
.github/FUNDING.yml
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
github: tale
|
||||
ko_fi: atale
|
||||
31
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
Normal file
31
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
Normal file
@ -0,0 +1,31 @@
|
||||
name: Bug Report
|
||||
description: Report an issue with Headplane
|
||||
assignees: [tale]
|
||||
labels: [bug, triage]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
A detailed description of the issue and steps to reproduce it.
|
||||
If applicable, include any error messages or screenshots.
|
||||
|
||||
If this is not an issue with Headplane, but an issue with your
|
||||
environment, please consider opening a discussion instead.
|
||||
placeholder: e.g. "When I try to upload a file, I get an error message."
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Headplane Version
|
||||
description: What version of Headplane are you using?
|
||||
placeholder: e.g. "v0.5.5"
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: Headscale Version
|
||||
description: What version of Headscale are you using?
|
||||
placeholder: e.g. "v0.25.1"
|
||||
validations:
|
||||
required: true
|
||||
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
1
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
15
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
Normal file
15
.github/ISSUE_TEMPLATE/feature-request.yaml
vendored
Normal file
@ -0,0 +1,15 @@
|
||||
name: Feature Request
|
||||
description: Request a new feature or enhancement for Headplane
|
||||
assignees: [tale]
|
||||
labels: [enhancement, triage]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
A detailed description of the feature you would like to see added.
|
||||
Please include any relevant context, such as why this feature is
|
||||
important and how it would benefit other users beyond yourself.
|
||||
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
|
||||
validations:
|
||||
required: true
|
||||
33
.github/workflows/automated.yaml
vendored
Normal file
33
.github/workflows/automated.yaml
vendored
Normal file
@ -0,0 +1,33 @@
|
||||
name: Automated
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 * * 0"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: automation-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: write # Allow canceling in-progress runs
|
||||
contents: write # Read/write access to the repository
|
||||
pull-requests: write # Allow creating pull requests
|
||||
|
||||
jobs:
|
||||
flake-inputs:
|
||||
name: flake-inputs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
|
||||
- uses: DeterminateSystems/update-flake-lock@main
|
||||
with:
|
||||
pr-title: "chore: update flake.lock"
|
||||
pr-labels: |
|
||||
automated
|
||||
76
.github/workflows/build.yaml
vendored
Normal file
76
.github/workflows/build.yaml
vendored
Normal file
@ -0,0 +1,76 @@
|
||||
name: Build
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- ".zed/**"
|
||||
- "assets/**"
|
||||
- "docs/**"
|
||||
- "CHANGELOG.md"
|
||||
- "README.md"
|
||||
branches:
|
||||
- "main"
|
||||
- "next"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: write # Allow canceling in-progress runs
|
||||
contents: read # Read access to the repository
|
||||
|
||||
jobs:
|
||||
native:
|
||||
name: native
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
nix:
|
||||
name: nix
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
|
||||
- name: Check flake inputs
|
||||
uses: DeterminateSystems/flake-checker-action@main
|
||||
|
||||
- name: Check flake outputs
|
||||
run: nix flake check --all-systems
|
||||
55
.github/workflows/next.yaml
vendored
Normal file
55
.github/workflows/next.yaml
vendored
Normal file
@ -0,0 +1,55 @@
|
||||
name: Pre-release (next)
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
concurrency:
|
||||
group: pre-release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: write # Allow canceling in-progress runs
|
||||
contents: read # Read access to the repository
|
||||
packages: write # Write access to the container registry
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Ensure the action only runs if manually dispatched or a PR on the `next` branch in the *main* repository is opened or synchronized.
|
||||
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
|
||||
name: Docker Pre-release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=raw,value=next
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
44
.github/workflows/nightly.yaml
vendored
44
.github/workflows/nightly.yaml
vendored
@ -1,44 +0,0 @@
|
||||
name: Publish Nightly Docker Image
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and Publish Nightly
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=edge,branch=main
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
20
.github/workflows/nix-update-lock.yaml
vendored
20
.github/workflows/nix-update-lock.yaml
vendored
@ -1,20 +0,0 @@
|
||||
name: Update flake.lock
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: "0 0 * * 0"
|
||||
|
||||
jobs:
|
||||
update-flake-inputs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
- uses: DeterminateSystems/update-flake-lock@main
|
||||
with:
|
||||
pr-title: "chore: update flake.lock"
|
||||
pr-labels: |
|
||||
dependencies
|
||||
automated
|
||||
- uses: DeterminateSystems/flake-checker-action@main
|
||||
- run: nix flake check --all-systems
|
||||
24
.github/workflows/nix.yaml
vendored
24
.github/workflows/nix.yaml
vendored
@ -1,24 +0,0 @@
|
||||
name: Nix CI
|
||||
on:
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- v?[0-9]+.[0-9]+.[0-9]+*
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
nix-ci:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: DeterminateSystems/nix-installer-action@main
|
||||
with:
|
||||
determinate: true
|
||||
- uses: DeterminateSystems/flake-checker-action@main
|
||||
- run: nix flake check --all-systems
|
||||
46
.github/workflows/publish.yaml
vendored
46
.github/workflows/publish.yaml
vendored
@ -1,46 +0,0 @@
|
||||
name: Publish Docker Image
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
name: Build and Publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=raw,value=latest,enable=false
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
44
.github/workflows/push.yaml
vendored
44
.github/workflows/push.yaml
vendored
@ -1,44 +0,0 @@
|
||||
name: "Build"
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- uses: pnpm/action-setup@v4
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
53
.github/workflows/release.yaml
vendored
Normal file
53
.github/workflows/release.yaml
vendored
Normal file
@ -0,0 +1,53 @@
|
||||
name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
concurrency:
|
||||
group: release-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
actions: write # Allow canceling in-progress runs
|
||||
contents: read # Read access to the repository
|
||||
packages: write # Write access to the container registry
|
||||
|
||||
jobs:
|
||||
docker:
|
||||
name: Docker Release
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Docker Metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to ghcr.io
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64, linux/arm64
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -2,4 +2,5 @@ node_modules
|
||||
/.react-router
|
||||
/.cache
|
||||
/build
|
||||
/test
|
||||
.env
|
||||
|
||||
2
.npmrc
2
.npmrc
@ -1,2 +1,2 @@
|
||||
side-effects-cache = false
|
||||
shamefully-hoist = true
|
||||
public-hoist-pattern[]=*hono*
|
||||
|
||||
2
.tool-versions
Normal file
2
.tool-versions
Normal file
@ -0,0 +1,2 @@
|
||||
pnpm 10.4.0
|
||||
node 22
|
||||
17
.zed/settings.json
Normal file
17
.zed/settings.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"formatter": {
|
||||
"language_server": {
|
||||
"name": "biome"
|
||||
}
|
||||
},
|
||||
"code_actions_on_format": {
|
||||
"source.fixAll.biome": true,
|
||||
"source.organizeImports.biome": true
|
||||
},
|
||||
"languages": {
|
||||
"YAML": {
|
||||
"tab_size": 2,
|
||||
"hard_tabs": false
|
||||
}
|
||||
}
|
||||
}
|
||||
42
CHANGELOG.md
42
CHANGELOG.md
@ -1,3 +1,45 @@
|
||||
### 0.5.10 (April 4, 2025)
|
||||
- Fix an issue where other prefernences to skip onboarding affected every user.
|
||||
|
||||
### 0.5.9 (April 3, 2025)
|
||||
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
|
||||
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
|
||||
- Suppress hydration errors for any fields with a date in it.
|
||||
|
||||
### 0.5.8 (April 3, 2025)
|
||||
- You can now skip the onboarding page if desired.
|
||||
- Added the UI to change user roles in the dashboard.
|
||||
- Fixed an issue where integrations would throw instead of loading properly.
|
||||
- Loading the ACL page no longer spams blank updates to the Headscale database (fixes [#151](https://github.com/tale/headplane/issues/151))
|
||||
- Automatically create `/var/lib/headplane` in the Docker container (fixes [#166](https://github.com/tale/headplane/issues/166))
|
||||
- OIDC logout with `disable_api_key_login` set to true will not automatically login again (fixes [#149](https://github.com/tale/headplane/issues/149))
|
||||
|
||||
### 0.5.7 (April 2, 2025)
|
||||
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
|
||||
|
||||
### 0.5.6 (April 2, 2025)
|
||||
|
||||
### IMPORTANT
|
||||
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously *ANY* accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
|
||||
|
||||
Implemented *proper* authentication methods for OIDC.
|
||||
This is a large update and copies the permission system from Tailscale.
|
||||
Permissions are not automatically derived from OIDC, but they can be configured via the UI.
|
||||
Additionally, certain roles give certain capabilities, limiting access to parts of the dashboard.
|
||||
By default, new users will have a `member` role which forbids access to the UI.
|
||||
If there are no users, the first user will be given an `owner` role which cannot be removed.
|
||||
|
||||
**Changes**:
|
||||
- Switched the internal server to use `hono` for better performance.
|
||||
- Fixed an issue that caused dialogs to randomly refocus every 3 seconds.
|
||||
- Headplane will not send API requests when the tab is not focused.
|
||||
- Continue loosening the configuration requirements for Headscale (part of an ongoing effort).
|
||||
- Unknown values in the Headplane config no longer cause a crash.
|
||||
- Fixed an issue that caused copied commands to have a random space (fixes [#161](https://github.com/tale/headplane/issues/161))
|
||||
|
||||
### 0.5.5 (March 18, 2025)
|
||||
- Hotfix an issue that caused Headplane to crash if no agents are available
|
||||
|
||||
### 0.5.4 (March 18, 2025)
|
||||
- Fixed a typo in the Kubernetes documentation
|
||||
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@ -9,15 +9,10 @@ RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
RUN pnpm prune --prod
|
||||
|
||||
FROM node:22-alpine
|
||||
RUN mkdir -p /var/lib/headplane
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/build /app/build
|
||||
COPY --from=build /app/node_modules /app/node_modules
|
||||
RUN echo '{"type":"module"}' > /app/package.json
|
||||
|
||||
EXPOSE 3000
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
CMD [ "node", "./build/headplane/server.js" ]
|
||||
CMD [ "node", "./build/server/index.js" ]
|
||||
|
||||
12
README.md
12
README.md
@ -44,6 +44,18 @@ There are 2 ways to deploy Headplane:
|
||||
Simple mode does not include the automatic management of DNS and Headplane
|
||||
settings, requiring manual editing and reloading when making changes.
|
||||
|
||||
### Versioning
|
||||
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
|
||||
Pre-release builds are available under the `next` tag and get updated when a new release
|
||||
PR is opened and actively in testing.
|
||||
|
||||
### Contributing
|
||||
Headplane is an open-source project and contributions are welcome! If you have
|
||||
any suggestions, bug reports, or feature requests, please open an issue. Also
|
||||
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
|
||||
|
||||
---
|
||||
|
||||
<picture>
|
||||
<source
|
||||
media="(prefers-color-scheme: dark)"
|
||||
|
||||
@ -53,5 +53,11 @@ func httpToWs(controlURL string) (string, error) {
|
||||
return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
|
||||
}
|
||||
|
||||
// We also need to append /_dial to the path
|
||||
if u.Path[len(u.Path)-1] != '/' {
|
||||
u.Path += "/"
|
||||
}
|
||||
|
||||
u.Path += "_dial"
|
||||
return u.String(), nil
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ export interface AttributeProps {
|
||||
value: string;
|
||||
isCopyable?: boolean;
|
||||
link?: string;
|
||||
suppressHydrationWarning?: boolean;
|
||||
}
|
||||
|
||||
export default function Attribute({
|
||||
@ -14,6 +15,7 @@ export default function Attribute({
|
||||
value,
|
||||
link,
|
||||
isCopyable,
|
||||
suppressHydrationWarning,
|
||||
}: AttributeProps) {
|
||||
return (
|
||||
<dl className="flex items-center w-full gap-x-1">
|
||||
@ -27,6 +29,7 @@ export default function Attribute({
|
||||
)}
|
||||
</dt>
|
||||
<dd
|
||||
suppressHydrationWarning={suppressHydrationWarning}
|
||||
className={cn(
|
||||
'rounded-lg truncate w-full px-2.5 py-1 text-sm',
|
||||
'flex items-center gap-x-1',
|
||||
@ -54,7 +57,12 @@ export default function Attribute({
|
||||
}, 1000);
|
||||
}}
|
||||
>
|
||||
<p className="truncate">{value}</p>
|
||||
<p
|
||||
suppressHydrationWarning={suppressHydrationWarning}
|
||||
className="truncate"
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
|
||||
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
|
||||
</button>
|
||||
|
||||
@ -24,9 +24,7 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
|
||||
type="button"
|
||||
className="bottom-0 right-0 absolute"
|
||||
onClick={async (event) => {
|
||||
const text = Array.isArray(children)
|
||||
? children.join(' ')
|
||||
: children;
|
||||
const text = Array.isArray(children) ? children.join('') : children;
|
||||
|
||||
const svgs = event.currentTarget.querySelectorAll('svg');
|
||||
for (const svg of svgs) {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { cloneElement, useRef } from 'react';
|
||||
import React, { cloneElement, useEffect, useRef } from 'react';
|
||||
import {
|
||||
type AriaDialogProps,
|
||||
type AriaModalOverlayProps,
|
||||
@ -19,6 +19,7 @@ import IconButton, { IconButtonProps } from '~/components/IconButton';
|
||||
import Text from '~/components/Text';
|
||||
import Title from '~/components/Title';
|
||||
import cn from '~/utils/cn';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
|
||||
export interface DialogProps extends OverlayTriggerProps {
|
||||
children:
|
||||
@ -30,6 +31,7 @@ export interface DialogProps extends OverlayTriggerProps {
|
||||
}
|
||||
|
||||
function Dialog(props: DialogProps) {
|
||||
const { pause, resume } = useLiveData();
|
||||
const state = useOverlayTriggerState(props);
|
||||
const { triggerProps, overlayProps } = useOverlayTrigger(
|
||||
{
|
||||
@ -38,6 +40,14 @@ function Dialog(props: DialogProps) {
|
||||
state,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.isOpen) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}, [state.isOpen]);
|
||||
|
||||
if (Array.isArray(props.children)) {
|
||||
const [button, panel] = props.children;
|
||||
return (
|
||||
|
||||
@ -1,16 +1,36 @@
|
||||
import { AlertIcon } from '@primer/octicons-react';
|
||||
import { isRouteErrorResponse, useRouteError } from 'react-router';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import cn from '~/utils/cn';
|
||||
import Card from './Card';
|
||||
import Code from './Code';
|
||||
|
||||
interface Props {
|
||||
type?: 'full' | 'embedded';
|
||||
}
|
||||
|
||||
function getMessage(error: Error | unknown) {
|
||||
function getMessage(error: Error | unknown): {
|
||||
title: string;
|
||||
message: string;
|
||||
} {
|
||||
if (error instanceof ResponseError) {
|
||||
if (error.responseObject?.message) {
|
||||
return {
|
||||
title: 'Headscale Error',
|
||||
message: String(error.responseObject.message),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Headscale Error',
|
||||
message: error.response,
|
||||
};
|
||||
}
|
||||
|
||||
if (!(error instanceof Error)) {
|
||||
return 'An unknown error occurred';
|
||||
return {
|
||||
title: 'Unknown Error',
|
||||
message: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
let rootError = error;
|
||||
@ -25,16 +45,22 @@ function getMessage(error: Error | unknown) {
|
||||
|
||||
// If we are aggregate, concat into a single message
|
||||
if (rootError instanceof AggregateError) {
|
||||
return rootError.errors.map((error) => error.message).join('\n');
|
||||
return {
|
||||
title: 'Errors',
|
||||
message: rootError.errors.map((error) => error.message).join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
return rootError.message;
|
||||
return {
|
||||
title: 'Error',
|
||||
message: rootError.message,
|
||||
};
|
||||
}
|
||||
|
||||
export function ErrorPopup({ type = 'full' }: Props) {
|
||||
const error = useRouteError();
|
||||
const routing = isRouteErrorResponse(error);
|
||||
const message = getMessage(error);
|
||||
const { title, message } = getMessage(error);
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -48,12 +74,14 @@ export function ErrorPopup({ type = 'full' }: Props) {
|
||||
<Card>
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-3xl mb-0">
|
||||
{routing ? error.status : 'Error'}
|
||||
{routing ? error.status : title}
|
||||
</Card.Title>
|
||||
<AlertIcon className="w-12 h-12 text-red-500" />
|
||||
</div>
|
||||
<Card.Text className="mt-4 text-lg">
|
||||
{routing ? error.statusText : <Code>{message}</Code>}
|
||||
<Card.Text
|
||||
className={cn('mt-4 text-lg', routing ? 'font-normal' : 'font-mono')}
|
||||
>
|
||||
{routing ? error.data.message : message}
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
import Link from '~/components/Link';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
declare global {
|
||||
const __VERSION__: string;
|
||||
}
|
||||
|
||||
interface FooterProps {
|
||||
url: string;
|
||||
debug: boolean;
|
||||
@ -34,7 +30,18 @@ export default function Footer({ url, debug }: FooterProps) {
|
||||
<p className="container text-xs opacity-75">
|
||||
Version: {__VERSION__}
|
||||
{' — '}
|
||||
Connecting to <strong className="blur-xs hover:blur-none">{url}</strong>
|
||||
Connecting to{' '}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={0} // Allows keyboard focus
|
||||
className={cn(
|
||||
'blur-sm hover:blur-none focus:blur-none transition',
|
||||
'focus:outline-none focus:ring-2 rounded-sm',
|
||||
)}
|
||||
>
|
||||
{url}
|
||||
</button>
|
||||
{/* Connecting to <strong className="blur-xs hover:blur-none">{url}</strong> */}
|
||||
{debug && ' (Debug mode enabled)'}
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
@ -10,12 +10,21 @@ import {
|
||||
import type { ReactNode } from 'react';
|
||||
import { NavLink, useSubmit } from 'react-router';
|
||||
import Menu from '~/components/Menu';
|
||||
import { AuthSession } from '~/server/web/sessions';
|
||||
import cn from '~/utils/cn';
|
||||
import type { SessionData } from '~/utils/sessions.server';
|
||||
|
||||
interface Props {
|
||||
configAvailable: boolean;
|
||||
user?: SessionData['user'];
|
||||
onboarding: boolean;
|
||||
user?: AuthSession['user'];
|
||||
access: {
|
||||
ui: boolean;
|
||||
machines: boolean;
|
||||
dns: boolean;
|
||||
users: boolean;
|
||||
policy: boolean;
|
||||
settings: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
interface LinkProps {
|
||||
@ -135,29 +144,49 @@ export default function Header(data: Props) {
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
|
||||
<TabLink
|
||||
to="/machines"
|
||||
name="Machines"
|
||||
icon={<Server className="w-5" />}
|
||||
/>
|
||||
<TabLink to="/users" name="Users" icon={<Users className="w-5" />} />
|
||||
<TabLink
|
||||
to="/acls"
|
||||
name="Access Control"
|
||||
icon={<Lock className="w-5" />}
|
||||
/>
|
||||
{data.configAvailable ? (
|
||||
<>
|
||||
<TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} />
|
||||
{data.access.ui && !data.onboarding ? (
|
||||
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
|
||||
{data.access.machines ? (
|
||||
<TabLink
|
||||
to="/settings"
|
||||
name="Settings"
|
||||
icon={<Settings className="w-5" />}
|
||||
to="/machines"
|
||||
name="Machines"
|
||||
icon={<Server className="w-5" />}
|
||||
/>
|
||||
</>
|
||||
) : undefined}
|
||||
</nav>
|
||||
) : undefined}
|
||||
{data.access.users ? (
|
||||
<TabLink
|
||||
to="/users"
|
||||
name="Users"
|
||||
icon={<Users className="w-5" />}
|
||||
/>
|
||||
) : undefined}
|
||||
{data.access.policy ? (
|
||||
<TabLink
|
||||
to="/acls"
|
||||
name="Access Control"
|
||||
icon={<Lock className="w-5" />}
|
||||
/>
|
||||
) : undefined}
|
||||
{data.configAvailable ? (
|
||||
<>
|
||||
{data.access.dns ? (
|
||||
<TabLink
|
||||
to="/dns"
|
||||
name="DNS"
|
||||
icon={<Globe2 className="w-5" />}
|
||||
/>
|
||||
) : undefined}
|
||||
{data.access.settings ? (
|
||||
<TabLink
|
||||
to="/settings"
|
||||
name="Settings"
|
||||
icon={<Settings className="w-5" />}
|
||||
/>
|
||||
) : undefined}
|
||||
</>
|
||||
) : undefined}
|
||||
</nav>
|
||||
) : undefined}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@ -16,6 +16,8 @@ import cn from '~/utils/cn';
|
||||
|
||||
interface MenuProps extends MenuTriggerProps {
|
||||
placement?: Placement;
|
||||
isDisabled?: boolean;
|
||||
disabledKeys?: Key[];
|
||||
children: [
|
||||
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
|
||||
React.ReactElement<MenuPanelProps>,
|
||||
@ -23,8 +25,9 @@ interface MenuProps extends MenuTriggerProps {
|
||||
}
|
||||
|
||||
// TODO: onAction is called twice for some reason?
|
||||
// TODO: isDisabled per-prop
|
||||
function Menu(props: MenuProps) {
|
||||
const { placement = 'bottom' } = props;
|
||||
const { placement = 'bottom', isDisabled, disabledKeys = [] } = props;
|
||||
const state = useMenuTriggerState(props);
|
||||
const ref = useRef<HTMLButtonElement | null>(null);
|
||||
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
|
||||
@ -40,6 +43,7 @@ function Menu(props: MenuProps) {
|
||||
<div>
|
||||
{cloneElement(button, {
|
||||
...menuTriggerProps,
|
||||
isDisabled: isDisabled,
|
||||
ref,
|
||||
})}
|
||||
{state.isOpen && (
|
||||
@ -48,6 +52,7 @@ function Menu(props: MenuProps) {
|
||||
...menuProps,
|
||||
autoFocus: state.focusStrategy ?? true,
|
||||
onClose: () => state.close(),
|
||||
disabledKeys,
|
||||
})}
|
||||
</Popover>
|
||||
)}
|
||||
@ -57,6 +62,7 @@ function Menu(props: MenuProps) {
|
||||
|
||||
interface MenuPanelProps extends AriaMenuProps<object> {
|
||||
onClose?: () => void;
|
||||
disabledKeys?: Key[];
|
||||
}
|
||||
|
||||
function Panel(props: MenuPanelProps) {
|
||||
@ -71,7 +77,12 @@ function Panel(props: MenuPanelProps) {
|
||||
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<MenuSection key={item.key} section={item} state={state} />
|
||||
<MenuSection
|
||||
key={item.key}
|
||||
section={item}
|
||||
state={state}
|
||||
disabledKeys={props.disabledKeys}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
@ -80,9 +91,10 @@ function Panel(props: MenuPanelProps) {
|
||||
interface MenuSectionProps<T> {
|
||||
section: Node<T>;
|
||||
state: TreeState<T>;
|
||||
disabledKeys?: Key[];
|
||||
}
|
||||
|
||||
function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
|
||||
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
|
||||
const { itemProps, groupProps } = useMenuSection({
|
||||
heading: section.rendered,
|
||||
'aria-label': section['aria-label'],
|
||||
@ -106,7 +118,12 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
|
||||
<li {...itemProps}>
|
||||
<ul {...groupProps}>
|
||||
{[...section.childNodes].map((item) => (
|
||||
<MenuItem key={item.key} item={item} state={state} />
|
||||
<MenuItem
|
||||
key={item.key}
|
||||
item={item}
|
||||
state={state}
|
||||
isDisabled={disabledKeys?.includes(item.key)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
@ -117,14 +134,14 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
|
||||
interface MenuItemProps<T> {
|
||||
item: Node<T>;
|
||||
state: TreeState<T>;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
function MenuItem<T>({ item, state }: MenuItemProps<T>) {
|
||||
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
|
||||
const ref = useRef<HTMLLIElement | null>(null);
|
||||
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
|
||||
|
||||
const isFocused = state.selectionManager.focusedKey === item.key;
|
||||
const isDisabled = state.selectionManager.isDisabled(item.key);
|
||||
|
||||
return (
|
||||
<li
|
||||
|
||||
78
app/components/Options.tsx
Normal file
78
app/components/Options.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
import { useRef } from 'react';
|
||||
import {
|
||||
AriaTabListProps,
|
||||
AriaTabPanelProps,
|
||||
useTab,
|
||||
useTabList,
|
||||
useTabPanel,
|
||||
} from 'react-aria';
|
||||
import { Item, Node, TabListState, useTabListState } from 'react-stately';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
export interface OptionsProps extends AriaTabListProps<object> {
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Options({ label, className, ...props }: OptionsProps) {
|
||||
const state = useTabListState(props);
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { tabListProps } = useTabList(props, state, ref);
|
||||
return (
|
||||
<div className={cn('flex flex-col', className)}>
|
||||
<div
|
||||
{...tabListProps}
|
||||
ref={ref}
|
||||
className="flex items-center gap-2 overflow-x-scroll"
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<Option key={item.key} item={item} state={state} />
|
||||
))}
|
||||
</div>
|
||||
<OptionsPanel key={state.selectedItem?.key} state={state} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OptionsOptionProps {
|
||||
item: Node<object>;
|
||||
state: TabListState<object>;
|
||||
}
|
||||
|
||||
function Option({ item, state }: OptionsOptionProps) {
|
||||
const { key, rendered } = item;
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { tabProps } = useTab({ key }, state, ref);
|
||||
return (
|
||||
<div
|
||||
{...tabProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
|
||||
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
|
||||
'focus:outline-none focus:ring z-10',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{rendered}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface OptionsPanelProps extends AriaTabPanelProps {
|
||||
state: TabListState<object>;
|
||||
}
|
||||
|
||||
function OptionsPanel({ state, ...props }: OptionsPanelProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const { tabPanelProps } = useTabPanel(props, state, ref);
|
||||
return (
|
||||
<div {...tabPanelProps} ref={ref} className="w-full mt-2">
|
||||
{state.selectedItem?.props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(Options, { Item });
|
||||
83
app/components/RadioGroup.tsx
Normal file
83
app/components/RadioGroup.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
import React, { createContext, useContext, useRef } from 'react';
|
||||
import {
|
||||
AriaRadioGroupProps,
|
||||
AriaRadioProps,
|
||||
VisuallyHidden,
|
||||
useFocusRing,
|
||||
} from 'react-aria';
|
||||
import { RadioGroupState } from 'react-stately';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import { useRadio, useRadioGroup } from 'react-aria';
|
||||
import { useRadioGroupState } from 'react-stately';
|
||||
|
||||
interface RadioGroupProps extends AriaRadioGroupProps {
|
||||
children: React.ReactElement<RadioProps>[];
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const RadioContext = createContext<RadioGroupState | null>(null);
|
||||
|
||||
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
|
||||
const state = useRadioGroupState(props);
|
||||
const { radioGroupProps, labelProps } = useRadioGroup(
|
||||
{
|
||||
...props,
|
||||
'aria-label': label,
|
||||
},
|
||||
state,
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
|
||||
<VisuallyHidden>
|
||||
<span {...labelProps}>{label}</span>
|
||||
</VisuallyHidden>
|
||||
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface RadioProps extends AriaRadioProps {
|
||||
label: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function Radio({ children, label, className, ...props }: RadioProps) {
|
||||
const state = useContext(RadioContext);
|
||||
const ref = useRef(null);
|
||||
const { inputProps, isSelected, isDisabled } = useRadio(
|
||||
{
|
||||
...props,
|
||||
'aria-label': label,
|
||||
},
|
||||
state!,
|
||||
ref,
|
||||
);
|
||||
const { isFocusVisible, focusProps } = useFocusRing();
|
||||
|
||||
return (
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<VisuallyHidden>
|
||||
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
|
||||
</VisuallyHidden>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
'w-5 h-5 aspect-square rounded-full p-1 border-2',
|
||||
'border border-headplane-600 dark:border-headplane-300',
|
||||
isFocusVisible ? 'ring-4' : '',
|
||||
isDisabled ? 'opacity-50 cursor-not-allowed' : '',
|
||||
isSelected
|
||||
? 'border-[6px] border-headplane-900 dark:border-headplane-100'
|
||||
: '',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default Object.assign(RadioGroup, { Radio });
|
||||
@ -1,34 +1,30 @@
|
||||
import { XCircleFillIcon } from '@primer/octicons-react';
|
||||
import { type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { Outlet, useLoaderData } from 'react-router';
|
||||
import { ErrorPopup } from '~/components/Error';
|
||||
import type { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import cn from '~/utils/cn';
|
||||
import { HeadscaleError, healthcheck, pull } from '~/utils/headscale';
|
||||
import { destroySession, getSession } from '~/utils/sessions.server';
|
||||
import { useLiveData } from '~/utils/useLiveData';
|
||||
import log from '~server/utils/log';
|
||||
import log from '~/utils/log';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
let healthy = false;
|
||||
try {
|
||||
healthy = await healthcheck();
|
||||
} catch (error) {
|
||||
log.debug('APIC', 'Healthcheck failed %o', error);
|
||||
}
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const healthy = await context.client.healthcheck();
|
||||
const session = await context.sessions.auth(request);
|
||||
|
||||
// We shouldn't session invalidate if Headscale is down
|
||||
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
|
||||
if (healthy) {
|
||||
// We can assert because shell ensures this is set
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
const apiKey = session.get('hsApiKey')!;
|
||||
|
||||
try {
|
||||
await pull('v1/apikey', apiKey);
|
||||
await context.client.get('v1/apikey', session.get('api_key')!);
|
||||
} catch (error) {
|
||||
if (error instanceof HeadscaleError) {
|
||||
log.debug('APIC', 'API Key validation failed %o', error);
|
||||
if (error instanceof ResponseError) {
|
||||
log.debug('api', 'API Key validation failed %o', error);
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await destroySession(session),
|
||||
'Set-Cookie': await context.sessions.destroy(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -41,7 +37,6 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
}
|
||||
|
||||
export default function Layout() {
|
||||
useLiveData({ interval: 3000 });
|
||||
const { healthy } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
@ -71,3 +66,7 @@ export default function Layout() {
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ErrorPopup type="embedded" />;
|
||||
}
|
||||
|
||||
@ -1,42 +1,168 @@
|
||||
import { CircleCheckIcon } from 'lucide-react';
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
Outlet,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Footer from '~/components/Footer';
|
||||
import Header from '~/components/Header';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
import { hp_getConfig } from '~server/context/global';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { User } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import toast from '~/utils/toast';
|
||||
|
||||
// This loads the bare minimum for the application to function
|
||||
// So we know that if context fails to load then well, oops?
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (!session.has('hsApiKey')) {
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (!session.has('api_key')) {
|
||||
// There is a session, but it's not valid
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroy(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Onboarding is only a feature of the OIDC flow
|
||||
if (context.oidc && !request.url.endsWith('/onboarding')) {
|
||||
let onboarded = false;
|
||||
|
||||
const sessionUser = session.get('user');
|
||||
if (sessionUser) {
|
||||
if (context.sessions.onboardForSubject(sessionUser.subject)) {
|
||||
// Assume onboarded
|
||||
onboarded = true;
|
||||
} else {
|
||||
try {
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.get('api_key')!,
|
||||
);
|
||||
|
||||
if (users.length === 0) {
|
||||
onboarded = false;
|
||||
}
|
||||
|
||||
const user = users.find((u) => {
|
||||
if (u.provider !== 'oidc') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = u.providerId?.split('/').pop();
|
||||
if (!subject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionUser = session.get('user');
|
||||
if (!sessionUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (context.sessions.onboardForSubject(sessionUser.subject)) {
|
||||
// Assume onboarded
|
||||
return true;
|
||||
}
|
||||
|
||||
return subject === sessionUser.subject;
|
||||
});
|
||||
|
||||
if (user) {
|
||||
onboarded = true;
|
||||
}
|
||||
} catch (e) {
|
||||
// If we cannot lookup users, just assume our user is onboarded
|
||||
log.debug('api', 'Failed to lookup users %o', e);
|
||||
onboarded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!onboarded) {
|
||||
return redirect('/onboarding');
|
||||
}
|
||||
}
|
||||
|
||||
const check = await context.sessions.check(request, Capabilities.ui_access);
|
||||
return {
|
||||
config: context.hs.c,
|
||||
url: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
configAvailable: context.hs.readable(),
|
||||
debug: context.config.debug,
|
||||
user: session.get('user'),
|
||||
uiAccess: check,
|
||||
access: {
|
||||
ui: await context.sessions.check(request, Capabilities.ui_access),
|
||||
dns: await context.sessions.check(request, Capabilities.read_network),
|
||||
users: await context.sessions.check(request, Capabilities.read_users),
|
||||
policy: await context.sessions.check(request, Capabilities.read_policy),
|
||||
machines: await context.sessions.check(
|
||||
request,
|
||||
Capabilities.read_machines,
|
||||
),
|
||||
settings: await context.sessions.check(
|
||||
request,
|
||||
Capabilities.read_feature,
|
||||
),
|
||||
},
|
||||
onboarding: request.url.endsWith('/onboarding'),
|
||||
};
|
||||
} catch {
|
||||
// No session, so we can just return
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
const context = hp_getConfig();
|
||||
const { mode, config } = hs_getConfig();
|
||||
|
||||
return {
|
||||
config,
|
||||
url: context.headscale.public_url ?? context.headscale.url,
|
||||
configAvailable: mode !== 'no',
|
||||
debug: context.debug,
|
||||
user: session.get('user'),
|
||||
};
|
||||
}
|
||||
|
||||
export default function Shell() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header {...data} />
|
||||
<Outlet />
|
||||
{/* Always show the outlet if we are onboarding */}
|
||||
{(data.onboarding ? true : data.uiAccess) ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<Card className="mx-auto w-fit mt-24">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-3xl mb-0">Connected</Card.Title>
|
||||
<CircleCheckIcon className="w-10 h-10" />
|
||||
</div>
|
||||
<Card.Text className="my-4 text-lg">
|
||||
Connect to Tailscale with your devices to access this Tailnet. Use
|
||||
this command to help you get started:
|
||||
</Card.Text>
|
||||
<Button
|
||||
className="flex text-md font-mono"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`tailscale up --login-server=${data.url}`,
|
||||
);
|
||||
|
||||
toast('Copied to clipboard');
|
||||
}}
|
||||
>
|
||||
tailscale up --login-server={data.url}
|
||||
</Button>
|
||||
<p className="text-xs mt-1 opacity-50 text-center">
|
||||
Click this button to copy the command.
|
||||
</p>
|
||||
<p className="mt-4 text-sm opacity-50">
|
||||
Your account does not have access to the UI. Please contact your
|
||||
administrator if you believe this is a mistake.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
<Footer {...data} />
|
||||
</>
|
||||
);
|
||||
|
||||
34
app/root.tsx
34
app/root.tsx
@ -12,6 +12,7 @@ import { ErrorPopup } from '~/components/Error';
|
||||
import ProgressBar from '~/components/ProgressBar';
|
||||
import ToastProvider from '~/components/ToastProvider';
|
||||
import stylesheet from '~/tailwind.css?url';
|
||||
import { LiveDataProvider } from '~/utils/live-data';
|
||||
import { useToastQueue } from '~/utils/toast';
|
||||
|
||||
export const meta: MetaFunction = () => [
|
||||
@ -29,21 +30,26 @@ export const links: LinksFunction = () => [
|
||||
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
const toastQueue = useToastQueue();
|
||||
|
||||
// LiveDataProvider is wrapped at the top level since dialogs and things
|
||||
// that control its state are usually open in portal containers which
|
||||
// are not a part of the normal React tree.
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
|
||||
{children}
|
||||
<ToastProvider queue={toastQueue} />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
<LiveDataProvider>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
|
||||
{children}
|
||||
<ToastProvider queue={toastQueue} />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
</LiveDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -11,12 +11,11 @@ export default [
|
||||
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
|
||||
route('/oidc/start', 'routes/auth/oidc-start.ts'),
|
||||
|
||||
// API
|
||||
route('/api/agent', 'routes/api/agent.ts'),
|
||||
|
||||
// All the main logged-in dashboard routes
|
||||
// Double nested to separate error propagations
|
||||
layout('layouts/shell.tsx', [
|
||||
route('/onboarding', 'routes/users/onboarding.tsx'),
|
||||
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'),
|
||||
layout('layouts/dashboard.tsx', [
|
||||
...prefix('/machines', [
|
||||
index('routes/machines/overview.tsx'),
|
||||
@ -24,7 +23,7 @@ export default [
|
||||
]),
|
||||
|
||||
route('/users', 'routes/users/overview.tsx'),
|
||||
route('/acls', 'routes/acls/editor.tsx'),
|
||||
route('/acls', 'routes/acls/overview.tsx'),
|
||||
route('/dns', 'routes/dns/overview.tsx'),
|
||||
|
||||
...prefix('/settings', [
|
||||
|
||||
113
app/routes/acls/acl-action.ts
Normal file
113
app/routes/acls/acl-action.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { data400, data403 } from '~/utils/res';
|
||||
|
||||
// We only check capabilities here and assume it is writable
|
||||
// If it isn't, it'll gracefully error anyways, since this means some
|
||||
// fishy client manipulation is happening.
|
||||
export async function aclAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_policy,
|
||||
);
|
||||
if (!check) {
|
||||
throw data403('You do not have permission to write to the ACL policy');
|
||||
}
|
||||
|
||||
// Try to write to the ACL policy via the API or via config file (TODO).
|
||||
const formData = await request.formData();
|
||||
const policyData = formData.get('policy')?.toString();
|
||||
if (!policyData) {
|
||||
throw data400('Missing `policy` in the form data.');
|
||||
}
|
||||
|
||||
try {
|
||||
const { policy, updatedAt } = await context.client.put<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>('v1/policy', session.get('api_key')!, {
|
||||
policy: policyData,
|
||||
});
|
||||
|
||||
return data({
|
||||
success: true,
|
||||
error: undefined,
|
||||
policy,
|
||||
updatedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
// This means Headscale returned a protobuf error to us
|
||||
// It also means we 100% know this is in database mode
|
||||
if (error instanceof ResponseError && error.responseObject?.message) {
|
||||
const message = error.responseObject.message as string;
|
||||
// This is stupid, refer to the link
|
||||
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
|
||||
if (message.includes('update is disabled')) {
|
||||
// This means the policy is not writable
|
||||
throw data403('Policy is not writable');
|
||||
}
|
||||
|
||||
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
|
||||
if (message.includes('parsing hujson')) {
|
||||
// This means the policy was invalid, return a 400
|
||||
// with the actual error message from Headscale
|
||||
const cutIndex = message.indexOf('err: hujson:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 12)}`
|
||||
: message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('unmarshalling policy')) {
|
||||
// This means the policy was invalid, return a 400
|
||||
// with the actual error message from Headscale
|
||||
const cutIndex = message.indexOf('err:');
|
||||
const trimmed =
|
||||
cutIndex > -1
|
||||
? `Syntax error: ${message.slice(cutIndex + 5)}`
|
||||
: message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes('empty policy')) {
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: 'Policy error: Supplied policy was empty',
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, this is a Headscale error that we can just propagate.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
64
app/routes/acls/acl-loader.ts
Normal file
64
app/routes/acls/acl-loader.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { LoaderFunctionArgs } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { data403 } from '~/utils/res';
|
||||
|
||||
// The logic for deciding policy factors is very complicated because
|
||||
// there are so many factors that need to be accounted for:
|
||||
// 1. Does the user have permission to read the policy?
|
||||
// 2. Does the user have permission to write to the policy?
|
||||
// 3. Is the Headscale policy in file or database mode?
|
||||
// If database, we can read/write easily via the API.
|
||||
// If in file mode, we can only write if context.config is available.
|
||||
// TODO: Consider adding back file editing mode instead of database
|
||||
export async function aclLoader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(request, Capabilities.read_policy);
|
||||
if (!check) {
|
||||
throw data403('You do not have permission to read the ACL policy.');
|
||||
}
|
||||
|
||||
const flags = {
|
||||
// Can the user write to the ACL policy
|
||||
access: await context.sessions.check(request, Capabilities.write_policy),
|
||||
writable: false,
|
||||
policy: '',
|
||||
};
|
||||
|
||||
// Try to load the ACL policy from the API.
|
||||
try {
|
||||
const { policy, updatedAt } = await context.client.get<{
|
||||
policy: string;
|
||||
updatedAt: string | null;
|
||||
}>('v1/policy', session.get('api_key')!);
|
||||
|
||||
// Successfully loaded the policy, mark it as readable
|
||||
// If `updatedAt` is null, it means the policy is in file mode.
|
||||
flags.writable = updatedAt !== null;
|
||||
flags.policy = policy;
|
||||
return flags;
|
||||
} catch (error) {
|
||||
// This means Headscale returned a protobuf error to us
|
||||
// It also means we 100% know this is in database mode
|
||||
if (error instanceof ResponseError && error.responseObject?.message) {
|
||||
const message = error.responseObject.message as string;
|
||||
// This is stupid, refer to the link
|
||||
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
|
||||
if (message.includes('acl policy not found')) {
|
||||
// This means the policy has never been initiated, and we can
|
||||
// write to it to get it started or ignore it.
|
||||
flags.policy = ''; // Start with an empty policy
|
||||
flags.writable = true;
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
|
||||
// Otherwise, this is a Headscale error that we can just propagate.
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -14,6 +14,7 @@ interface EditorProps {
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
// TODO: Remove ClientOnly
|
||||
export function Editor(props: EditorProps) {
|
||||
const [light, setLight] = useState(false);
|
||||
useEffect(() => {
|
||||
@ -38,6 +39,8 @@ export function Editor(props: EditorProps) {
|
||||
{() => (
|
||||
<CodeMirror
|
||||
value={props.value}
|
||||
editable={!props.isDisabled} // Allow editing unless disabled
|
||||
readOnly={props.isDisabled} // Use readOnly if disabled
|
||||
height="100%"
|
||||
extensions={[shopify.jsonc()]}
|
||||
style={{ height: '100%' }}
|
||||
|
||||
@ -1,24 +1,44 @@
|
||||
import { AlertIcon } from '@primer/octicons-react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import React from 'react';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
|
||||
interface Props {
|
||||
message: string;
|
||||
interface NoticeViewProps {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ErrorView({ message }: Props) {
|
||||
export function NoticeView({ children, title }: NoticeViewProps) {
|
||||
return (
|
||||
<Card variant="flat" className="max-w-full mb-4">
|
||||
<Card variant="flat" className="max-w-2xl my-8">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-xl mb-0">Error</Card.Title>
|
||||
<Card.Title className="text-xl mb-0">{title}</Card.Title>
|
||||
<AlertIcon className="w-8 h-8 text-yellow-500" />
|
||||
</div>
|
||||
<Card.Text className="mt-4">{children}</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface ErrorViewProps {
|
||||
children: string;
|
||||
}
|
||||
|
||||
export function ErrorView({ children }: ErrorViewProps) {
|
||||
const [title, ...rest] = children.split(':');
|
||||
const formattedMessage = rest.length > 0 ? rest.join(':').trim() : children;
|
||||
|
||||
return (
|
||||
<Card variant="flat" className="max-w-2xl mb-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-xl mb-0">
|
||||
{title.trim() ?? 'Error'}
|
||||
</Card.Title>
|
||||
<AlertIcon className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<Card.Text className="mt-4">
|
||||
Could not apply changes to your ACL policy due to the following error:
|
||||
Could not apply changes to the ACL policy:
|
||||
<br />
|
||||
<Code>{message}</Code>
|
||||
<span className="font-mono">{formattedMessage}</span>
|
||||
</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import Spinner from '~/components/Spinner';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
interface Props {
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
import { AlertIcon } from '@primer/octicons-react';
|
||||
import cn from '~/utils/cn';
|
||||
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
|
||||
interface Props {
|
||||
mode: 'file' | 'database';
|
||||
}
|
||||
|
||||
export function Unavailable({ mode }: Props) {
|
||||
return (
|
||||
<Card variant="flat" className="max-w-prose mt-12">
|
||||
<div className="flex items-center justify-between">
|
||||
<Card.Title className="text-xl mb-0">ACL Policy Unavailable</Card.Title>
|
||||
<AlertIcon className="w-8 h-8 text-red-500" />
|
||||
</div>
|
||||
<Card.Text className="mt-4">
|
||||
Unable to load a valid ACL policy configuration. This is most likely due
|
||||
to a misconfiguration in your Headscale configuration file.
|
||||
</Card.Text>
|
||||
|
||||
{mode !== 'file' ? (
|
||||
<p className="mt-4 text-sm">
|
||||
According to your configuration, the ACL policy mode is set to{' '}
|
||||
<Code>file</Code> but the ACL file is not available. Ensure that the{' '}
|
||||
<Code>policy.path</Code> is set to a valid path in your Headscale
|
||||
configuration.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-4 text-sm">
|
||||
In order to fully utilize the ACL management features of Headplane,
|
||||
please set <Code>policy.mode</Code> to either <Code>file</Code> or{' '}
|
||||
<Code>database</Code> in your Headscale configuration.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@ -1,300 +0,0 @@
|
||||
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useFetcher, useLoaderData, useRevalidator } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import Spinner from '~/components/Spinner';
|
||||
import Tabs from '~/components/Tabs';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import { HeadscaleError, pull, put } from '~/utils/headscale';
|
||||
import { send } from '~/utils/res';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import toast from '~/utils/toast';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
import log from '~server/utils/log';
|
||||
import { Differ, Editor } from './components/cm.client';
|
||||
import { ErrorView } from './components/error';
|
||||
import { Unavailable } from './components/unavailable';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
|
||||
// The way policy is handled in 0.23 of Headscale and later is verbose.
|
||||
// The 2 ACL policy modes are either the database one or file one
|
||||
//
|
||||
// File: The ACL policy is readonly to the API and manually edited
|
||||
// Database: The ACL policy is read/write to the API
|
||||
//
|
||||
// To determine if we first have an ACL policy available we need to check
|
||||
// if fetching the v1/policy route gives us a 500 status code or a 200.
|
||||
//
|
||||
// 500 can mean many different things here unfortunately:
|
||||
// - In file based that means the file is not accessible
|
||||
// - In database mode this can mean that we have never set an ACL policy
|
||||
// - In database mode this can mean that the ACL policy is not available
|
||||
// - A general server error may have occurred
|
||||
//
|
||||
// Unfortunately the server errors are not very descriptive so we have to
|
||||
// do some silly guesswork here. If we are running in an integration mode
|
||||
// and have the Headscale configuration available to us, our assumptions
|
||||
// can be more accurate, otherwise we just HAVE to assume that the ACL
|
||||
// policy has never been set.
|
||||
//
|
||||
// We can do damage control by checking for write access and if we are not
|
||||
// able to PUT an ACL policy on the v1/policy route, we can already know
|
||||
// that the policy is at the very-least readonly or not available.
|
||||
const { mode, config } = hs_getConfig();
|
||||
let modeGuess = 'database'; // Assume database mode
|
||||
if (mode !== 'no') {
|
||||
modeGuess = config.policy?.mode ?? 'database';
|
||||
}
|
||||
|
||||
// Attempt to load the policy, for both the frontend and for checking
|
||||
// if we are able to write to the policy for write access
|
||||
try {
|
||||
const { policy } = await pull<{ policy: string }>(
|
||||
'v1/policy',
|
||||
session.get('hsApiKey')!,
|
||||
);
|
||||
|
||||
let write = false; // On file mode we already know it's readonly
|
||||
if (modeGuess === 'database' && policy.length > 0) {
|
||||
try {
|
||||
await put('v1/policy', session.get('hsApiKey')!, {
|
||||
policy: policy,
|
||||
});
|
||||
|
||||
write = true;
|
||||
} catch (error) {
|
||||
write = false;
|
||||
log.debug('APIC', 'Failed to write to ACL policy with error %s', error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
read: true,
|
||||
write,
|
||||
mode: modeGuess,
|
||||
policy,
|
||||
};
|
||||
} catch {
|
||||
// If we are explicit on file mode then this is the end of the road
|
||||
if (modeGuess === 'file') {
|
||||
return {
|
||||
read: false,
|
||||
write: false,
|
||||
mode: modeGuess,
|
||||
policy: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Assume that we have write access otherwise?
|
||||
// This is sort of a brittle assumption to make but we don't want
|
||||
// to create a default policy if we don't have to.
|
||||
return {
|
||||
read: true,
|
||||
write: true,
|
||||
mode: modeGuess,
|
||||
policy: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (!session.has('hsApiKey')) {
|
||||
return send({ success: false, error: null }, 401);
|
||||
}
|
||||
|
||||
try {
|
||||
const { acl } = (await request.json()) as { acl: string };
|
||||
const { policy } = await put<{ policy: string }>(
|
||||
'v1/policy',
|
||||
session.get('hsApiKey')!,
|
||||
{
|
||||
policy: acl,
|
||||
},
|
||||
);
|
||||
|
||||
return { success: true, policy, error: null };
|
||||
} catch (error) {
|
||||
log.debug('APIC', 'Failed to update ACL policy with error %s', error);
|
||||
|
||||
// @ts-ignore: TODO: Shut UP we know it's a string most of the time
|
||||
const text = JSON.parse(error.message);
|
||||
return send(
|
||||
{ success: false, error: text.message },
|
||||
{
|
||||
status: error instanceof HeadscaleError ? error.status : 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const fetcher = useFetcher<typeof action>();
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
const [acl, setAcl] = useState(data.policy ?? '');
|
||||
const [toasted, setToasted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data || toasted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetcher.data.success) {
|
||||
toast('Updated tailnet ACL policy');
|
||||
} else {
|
||||
toast('Failed to update tailnet ACL policy');
|
||||
}
|
||||
|
||||
setToasted(true);
|
||||
if (revalidator.state === 'idle') {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
}, [fetcher.data, toasted, data.policy]);
|
||||
|
||||
// The state for if the save and discard buttons should be disabled
|
||||
// is pretty complicated to calculate and varies on different states.
|
||||
const disabled = useMemo(() => {
|
||||
if (!data.read || !data.write) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// First check our fetcher states
|
||||
if (fetcher.state === 'loading') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (revalidator.state === 'loading') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If we have a failed fetcher state allow the user to try again
|
||||
if (fetcher.data?.success === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return data.policy === acl;
|
||||
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data.read && !data.write ? (
|
||||
<div className="mb-4">
|
||||
<Notice>
|
||||
The ACL policy is read-only. You can view the current policy but you
|
||||
cannot make changes to it.
|
||||
<br />
|
||||
To resolve this, you need to set the ACL policy mode to database in
|
||||
your Headscale configuration.
|
||||
</Notice>
|
||||
</div>
|
||||
) : undefined}
|
||||
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
|
||||
<p className="mb-4 max-w-prose">
|
||||
The ACL file is used to define the access control rules for your
|
||||
network. You can find more information about the ACL file in the{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1018/acls"
|
||||
name="Tailscale ACL documentation"
|
||||
>
|
||||
Tailscale ACL guide
|
||||
</Link>{' '}
|
||||
and the{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/acls/"
|
||||
name="Headscale ACL documentation"
|
||||
>
|
||||
Headscale docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
{fetcher.data?.success === false ? (
|
||||
<ErrorView message={fetcher.data.error} />
|
||||
) : undefined}
|
||||
{data.read ? (
|
||||
<>
|
||||
<Tabs label="ACL Editor" className="mb-4">
|
||||
<Tabs.Item
|
||||
key="edit"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="p-1" />
|
||||
<span>Edit file</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Editor isDisabled={!data.write} value={acl} onChange={setAcl} />
|
||||
</Tabs.Item>
|
||||
<Tabs.Item
|
||||
key="diff"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="p-1" />
|
||||
<span>Preview changes</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Differ left={data?.policy ?? ''} right={acl} />
|
||||
</Tabs.Item>
|
||||
<Tabs.Item
|
||||
key="preview"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="p-1" />
|
||||
<span>Preview rules</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<Construction />
|
||||
<p className="w-1/2 text-center mt-4">
|
||||
Previewing rules is not available yet. This feature is still
|
||||
in development and is pretty complicated to implement.
|
||||
Hopefully I will be able to get to it soon.
|
||||
</p>
|
||||
</div>
|
||||
</Tabs.Item>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
setToasted(false);
|
||||
fetcher.submit(
|
||||
{
|
||||
acl,
|
||||
},
|
||||
{
|
||||
method: 'PATCH',
|
||||
encType: 'application/json',
|
||||
},
|
||||
);
|
||||
}}
|
||||
>
|
||||
{fetcher.state === 'idle' ? undefined : (
|
||||
<Spinner className="w-3 h-3" />
|
||||
)}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={disabled}
|
||||
onPress={() => {
|
||||
setAcl(data?.policy ?? '');
|
||||
}}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Unavailable mode={data.mode as 'database' | 'file'} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
app/routes/acls/overview.tsx
Normal file
173
app/routes/acls/overview.tsx
Normal file
@ -0,0 +1,173 @@
|
||||
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
useFetcher,
|
||||
useLoaderData,
|
||||
useRevalidator,
|
||||
} from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import Tabs from '~/components/Tabs';
|
||||
import type { LoadContext } from '~/server';
|
||||
import toast from '~/utils/toast';
|
||||
import { aclAction } from './acl-action';
|
||||
import { aclLoader } from './acl-loader';
|
||||
import { Differ, Editor } from './components/cm.client';
|
||||
import { ErrorView, NoticeView } from './components/error';
|
||||
|
||||
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
|
||||
return aclLoader(request);
|
||||
}
|
||||
|
||||
export async function action(request: ActionFunctionArgs<LoadContext>) {
|
||||
return aclAction(request);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
// Access is a write check here, we already check read in aclLoader
|
||||
const { access, writable, policy } = useLoaderData<typeof loader>();
|
||||
const [codePolicy, setCodePolicy] = useState(policy);
|
||||
const fetcher = useFetcher<typeof action>();
|
||||
const { revalidate } = useRevalidator();
|
||||
const disabled = !access || !writable; // Disable if no permission or not writable
|
||||
|
||||
useEffect(() => {
|
||||
// Update the codePolicy when the loader data changes
|
||||
if (policy !== codePolicy) {
|
||||
setCodePolicy(policy);
|
||||
}
|
||||
}, [policy]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!fetcher.data) {
|
||||
// No data yet, return
|
||||
return;
|
||||
}
|
||||
|
||||
if (fetcher.data.success === true) {
|
||||
toast('Updated policy');
|
||||
revalidate();
|
||||
}
|
||||
}, [fetcher.data]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{!access ? (
|
||||
<NoticeView title="ACL Policy restricted">
|
||||
You do not have the necessary permissions to edit the Access Control
|
||||
List policy. Please contact your administrator to request access or to
|
||||
make changes to the ACL policy.
|
||||
</NoticeView>
|
||||
) : !writable ? (
|
||||
<NoticeView title="Read-only ACL Policy">
|
||||
The ACL policy mode is most likely set to <Code>file</Code> in your
|
||||
Headscale configuration. This means that the ACL file cannot be edited
|
||||
through the web interface. In order to resolve this, you'll need to
|
||||
set <Code>acl.mode</Code> to <Code>database</Code> in your Headscale
|
||||
configuration.
|
||||
</NoticeView>
|
||||
) : undefined}
|
||||
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
|
||||
<p className="mb-4 max-w-prose">
|
||||
The ACL file is used to define the access control rules for your
|
||||
network. You can find more information about the ACL file in the{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1018/acls"
|
||||
name="Tailscale ACL documentation"
|
||||
>
|
||||
Tailscale ACL guide
|
||||
</Link>{' '}
|
||||
and the{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/acls/"
|
||||
name="Headscale ACL documentation"
|
||||
>
|
||||
Headscale docs
|
||||
</Link>
|
||||
.
|
||||
</p>
|
||||
{fetcher.data?.error !== undefined ? (
|
||||
<ErrorView>{fetcher.data.error}</ErrorView>
|
||||
) : undefined}
|
||||
<Tabs label="ACL Editor" className="mb-4">
|
||||
<Tabs.Item
|
||||
key="edit"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Pencil className="p-1" />
|
||||
<span>Edit file</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Editor
|
||||
isDisabled={disabled}
|
||||
value={codePolicy}
|
||||
onChange={setCodePolicy}
|
||||
/>
|
||||
</Tabs.Item>
|
||||
<Tabs.Item
|
||||
key="diff"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="p-1" />
|
||||
<span>Preview changes</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Differ left={policy} right={codePolicy} />
|
||||
</Tabs.Item>
|
||||
<Tabs.Item
|
||||
key="preview"
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<FlaskConical className="p-1" />
|
||||
<span>Preview rules</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col items-center py-8">
|
||||
<Construction />
|
||||
<p className="w-1/2 text-center mt-4">
|
||||
Previewing rules is not available yet. This feature is still in
|
||||
development and is pretty complicated to implement. Hopefully I
|
||||
will be able to get to it soon.
|
||||
</p>
|
||||
</div>
|
||||
</Tabs.Item>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={
|
||||
disabled ||
|
||||
fetcher.state !== 'idle' ||
|
||||
codePolicy.length === 0 ||
|
||||
codePolicy === policy
|
||||
}
|
||||
onPress={() => {
|
||||
const formData = new FormData();
|
||||
console.log(codePolicy);
|
||||
formData.append('policy', codePolicy);
|
||||
fetcher.submit(formData, { method: 'PATCH' });
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
isDisabled={
|
||||
disabled || fetcher.state !== 'idle' || codePolicy === policy
|
||||
}
|
||||
onPress={() => {
|
||||
// Reset the editor to the original policy
|
||||
setCodePolicy(policy);
|
||||
}}
|
||||
>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,39 +0,0 @@
|
||||
import { LoaderFunctionArgs } from 'react-router';
|
||||
import { hp_getSingleton, hp_getSingletonUnsafe } from '~server/context/global';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const data = hp_getSingletonUnsafe('ws_agent_data');
|
||||
|
||||
if (!data) {
|
||||
return new Response(JSON.stringify({ error: 'Agent data unavailable' }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const qp = new URLSearchParams(request.url.split('?')[1]);
|
||||
const nodeIds = qp.get('node_ids')?.split(',');
|
||||
if (!nodeIds) {
|
||||
return new Response(JSON.stringify({ error: 'No node IDs provided' }), {
|
||||
status: 400,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const entries = data.toJSON();
|
||||
const missing = nodeIds.filter((nodeID) => !entries[nodeID]);
|
||||
if (missing.length > 0) {
|
||||
const requestCall = hp_getSingleton('ws_fetch_data');
|
||||
requestCall(missing);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(data), {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
@ -1,57 +1,60 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
type ActionFunctionArgs,
|
||||
type LoaderFunctionArgs,
|
||||
redirect,
|
||||
useSearchParams,
|
||||
} from 'react-router';
|
||||
import { Form, useActionData, useLoaderData } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
import Input from '~/components/Input';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { Key } from '~/types';
|
||||
import { pull } from '~/utils/headscale';
|
||||
import { commitSession, getSession } from '~/utils/sessions.server';
|
||||
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (session.has('hsApiKey')) {
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const context = hp_getConfig();
|
||||
const disableApiKeyLogin = context.oidc?.disable_api_key_login;
|
||||
let oidc = false;
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const state = qp.get('s');
|
||||
|
||||
try {
|
||||
// Only set if OIDC is properly enabled anyways
|
||||
hp_getSingleton('oidc_client');
|
||||
oidc = true;
|
||||
|
||||
if (disableApiKeyLogin) {
|
||||
return redirect('/oidc/start');
|
||||
const session = await context.sessions.auth(request);
|
||||
if (session.has('api_key')) {
|
||||
return redirect('/machines');
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const disableApiKeyLogin = context.config.oidc?.disable_api_key_login;
|
||||
if (context.oidc && disableApiKeyLogin) {
|
||||
// Prevents automatic redirect loop if OIDC is enabled and API key login is disabled
|
||||
// Since logging out would just log back in based on the redirects
|
||||
|
||||
if (state !== 'logout') {
|
||||
return redirect('/oidc/start');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oidc,
|
||||
apiKey: !disableApiKeyLogin,
|
||||
oidc: context.oidc,
|
||||
disableApiKeyLogin,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const formData = await request.formData();
|
||||
const oidcStart = formData.get('oidc-start');
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
const session = await context.sessions.getOrCreate(request);
|
||||
|
||||
if (oidcStart) {
|
||||
const context = hp_getConfig();
|
||||
if (!context.oidc) {
|
||||
throw new Error('An invalid OIDC configuration was provided');
|
||||
throw new Error('OIDC is not enabled');
|
||||
}
|
||||
|
||||
return redirect('/oidc/start');
|
||||
@ -61,17 +64,24 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
|
||||
// Test the API key
|
||||
try {
|
||||
const apiKeys = await pull<{ apiKeys: Key[] }>('v1/apikey', apiKey);
|
||||
const apiKeys = await context.client.get<{ apiKeys: Key[] }>(
|
||||
'v1/apikey',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
|
||||
if (!key) {
|
||||
throw new Error('Invalid API key');
|
||||
return {
|
||||
error: 'Invalid API key',
|
||||
};
|
||||
}
|
||||
|
||||
const expiry = new Date(key.expiration);
|
||||
const expiresIn = expiry.getTime() - Date.now();
|
||||
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
|
||||
|
||||
session.set('hsApiKey', apiKey);
|
||||
session.set('state', 'auth');
|
||||
session.set('api_key', apiKey);
|
||||
session.set('user', {
|
||||
subject: 'unknown-non-oauth',
|
||||
name: key.prefix,
|
||||
@ -80,7 +90,7 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session, {
|
||||
'Set-Cookie': await context.sessions.commit(session, {
|
||||
maxAge: expiresIn,
|
||||
}),
|
||||
},
|
||||
@ -93,14 +103,47 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { state, disableApiKeyLogin, oidc } = useLoaderData<typeof loader>();
|
||||
const actionData = useActionData<typeof action>();
|
||||
const [params] = useSearchParams();
|
||||
|
||||
useEffect(() => {
|
||||
// State is a one time thing, we need to remove it after it has
|
||||
// been consumed to prevent logic loops.
|
||||
if (state !== null) {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
searchParams.delete('s');
|
||||
|
||||
// Replacing because it's not a navigation, just a cleanup of the URL
|
||||
// We can't use the useSearchParams method since it revalidates
|
||||
// which will trigger a full reload
|
||||
const newUrl = searchParams.toString()
|
||||
? `{${window.location.pathname}?${searchParams.toString()}`
|
||||
: window.location.pathname;
|
||||
|
||||
window.history.replaceState(null, '', newUrl);
|
||||
}
|
||||
}, [state, params]);
|
||||
|
||||
if (state === 'logout') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
|
||||
<Card.Title>You have been logged out</Card.Title>
|
||||
<Card.Text>
|
||||
You can now close this window. If you would like to log in again,
|
||||
please refresh the page.
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
|
||||
<Card.Title>Welcome to Headplane</Card.Title>
|
||||
{data.apiKey ? (
|
||||
{!disableApiKeyLogin ? (
|
||||
<Form method="post">
|
||||
<Card.Text>
|
||||
Enter an API key to authenticate with Headplane. You can generate
|
||||
@ -125,19 +168,12 @@ export default function Page() {
|
||||
</Button>
|
||||
</Form>
|
||||
) : undefined}
|
||||
{data.oidc === true ? (
|
||||
{oidc ? (
|
||||
<Form method="POST">
|
||||
{!data.apiKey ? (
|
||||
<Card.Text className="mb-6">
|
||||
Sign in with your authentication provider to continue. Your
|
||||
administrator has disabled API key login.
|
||||
</Card.Text>
|
||||
) : undefined}
|
||||
|
||||
<input type="hidden" name="oidc-start" value="true" />
|
||||
<Button
|
||||
className="w-full mt-2"
|
||||
variant={data.apiKey ? 'light' : 'heavy'}
|
||||
variant={disableApiKeyLogin ? 'heavy' : 'light'}
|
||||
type="submit"
|
||||
>
|
||||
Single Sign-On
|
||||
|
||||
@ -1,15 +1,28 @@
|
||||
import { type ActionFunctionArgs, redirect } from 'react-router';
|
||||
import { destroySession, getSession } from '~/utils/sessions.server';
|
||||
import type { LoadContext } from '~/server';
|
||||
|
||||
export async function loader() {
|
||||
return redirect('/machines');
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
return redirect('/login', {
|
||||
export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (!session.has('api_key')) {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
// When API key is disabled, we need to explicitly redirect
|
||||
// with a logout state to prevent auto login again.
|
||||
const url = context.config.oidc?.disable_api_key_login
|
||||
? '/login?s=logout'
|
||||
: '/login';
|
||||
|
||||
return redirect(url, {
|
||||
headers: {
|
||||
'Set-Cookie': await destroySession(session),
|
||||
'Set-Cookie': await context.sessions.destroy(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,68 +1,61 @@
|
||||
import { type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions';
|
||||
import { finishAuthFlow, formatError } from '~/utils/oidc';
|
||||
import { send } from '~/utils/res';
|
||||
import { commitSession, getSession } from '~/utils/sessions.server';
|
||||
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const { oidc } = hp_getConfig();
|
||||
try {
|
||||
if (!oidc) {
|
||||
throw new Error('OIDC is not enabled');
|
||||
}
|
||||
|
||||
hp_getSingleton('oidc_client');
|
||||
} catch {
|
||||
return send({ error: 'OIDC is not enabled' }, { status: 400 });
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
if (!context.oidc) {
|
||||
throw new Error('OIDC is not enabled');
|
||||
}
|
||||
|
||||
// Check if we have 0 query parameters
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.toString().length === 0) {
|
||||
return redirect('/machines');
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (session.has('hsApiKey')) {
|
||||
return redirect('/machines');
|
||||
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
|
||||
if (session.get('state') !== 'flow') {
|
||||
return redirect('/login'); // Haven't started an OIDC flow
|
||||
}
|
||||
|
||||
const codeVerifier = session.get('oidc_code_verif');
|
||||
const state = session.get('oidc_state');
|
||||
const nonce = session.get('oidc_nonce');
|
||||
const redirectUri = session.get('oidc_redirect_uri');
|
||||
|
||||
if (!codeVerifier || !state || !nonce || !redirectUri) {
|
||||
const payload = session.get('oidc')!;
|
||||
const { code_verifier, state, nonce, redirect_uri } = payload;
|
||||
if (!code_verifier || !state || !nonce || !redirect_uri) {
|
||||
return send({ error: 'Missing OIDC state' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Reconstruct the redirect URI using the query parameters
|
||||
// and the one we saved in the session
|
||||
const flowRedirectUri = new URL(redirectUri);
|
||||
const flowRedirectUri = new URL(redirect_uri);
|
||||
flowRedirectUri.search = url.search;
|
||||
|
||||
const flowOptions = {
|
||||
redirect_uri: flowRedirectUri.toString(),
|
||||
codeVerifier,
|
||||
code_verifier,
|
||||
state,
|
||||
nonce: nonce === '<none>' ? undefined : nonce,
|
||||
};
|
||||
|
||||
try {
|
||||
const user = await finishAuthFlow(oidc, flowOptions);
|
||||
session.set('user', user);
|
||||
session.unset('oidc_code_verif');
|
||||
session.unset('oidc_state');
|
||||
session.unset('oidc_nonce');
|
||||
const user = await finishAuthFlow(context.oidc, flowOptions);
|
||||
session.unset('oidc');
|
||||
const userSession = session as Session<AuthSession>;
|
||||
|
||||
// TODO: This is breaking, to stop the "over-generation" of API
|
||||
// keys because they are currently non-deletable in the headscale
|
||||
// database. Look at this in the future once we have a solution
|
||||
// or we have permissioned API keys.
|
||||
session.set('hsApiKey', oidc.headscale_api_key);
|
||||
userSession.set('user', user);
|
||||
userSession.set('api_key', context.config.oidc?.headscale_api_key!);
|
||||
userSession.set('state', 'auth');
|
||||
return redirect('/machines', {
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
'Set-Cookie': await context.sessions.commit(userSession),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@ -1,37 +1,42 @@
|
||||
import { type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
|
||||
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
|
||||
import { send } from '~/utils/res';
|
||||
import { commitSession, getSession } from '~/utils/sessions.server';
|
||||
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (session.has('hsApiKey')) {
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
|
||||
if ((session as Session<AuthSession>).has('api_key')) {
|
||||
return redirect('/machines');
|
||||
}
|
||||
|
||||
const { oidc } = hp_getConfig();
|
||||
try {
|
||||
if (!oidc) {
|
||||
throw new Error('OIDC is not enabled');
|
||||
}
|
||||
|
||||
hp_getSingleton('oidc_client');
|
||||
} catch {
|
||||
return send({ error: 'OIDC is not enabled' }, { status: 400 });
|
||||
if (!context.oidc) {
|
||||
throw new Error('OIDC is not enabled');
|
||||
}
|
||||
|
||||
const redirectUri = oidc.redirect_uri ?? getRedirectUri(request);
|
||||
const data = await beginAuthFlow(oidc, redirectUri);
|
||||
session.set('oidc_code_verif', data.codeVerifier);
|
||||
session.set('oidc_state', data.state);
|
||||
session.set('oidc_nonce', data.nonce);
|
||||
session.set('oidc_redirect_uri', redirectUri);
|
||||
const redirectUri =
|
||||
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
|
||||
const data = await beginAuthFlow(
|
||||
context.oidc,
|
||||
redirectUri,
|
||||
// We can't get here without the OIDC config being defined
|
||||
context.config.oidc!.token_endpoint_auth_method,
|
||||
);
|
||||
|
||||
session.set('state', 'flow');
|
||||
session.set('oidc', {
|
||||
state: data.state,
|
||||
nonce: data.nonce,
|
||||
code_verifier: data.codeVerifier,
|
||||
redirect_uri: redirectUri,
|
||||
});
|
||||
|
||||
return redirect(data.url, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Set-Cookie': await commitSession(session),
|
||||
'Set-Cookie': await context.sessions.commit(session),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,16 +1,21 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { hs_getConfig, hs_patchConfig } from '~/utils/config/loader';
|
||||
import { hp_getIntegration } from '~/utils/integration/loader';
|
||||
import { auth } from '~/utils/sessions.server';
|
||||
import { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
|
||||
export async function dnsAction({ request }: ActionFunctionArgs) {
|
||||
const session = await auth(request);
|
||||
if (!session) {
|
||||
return data({ success: false }, 401);
|
||||
export async function dnsAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_network,
|
||||
);
|
||||
|
||||
if (!check) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
const { mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
if (!context.hs.writable()) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
@ -22,59 +27,60 @@ export async function dnsAction({ request }: ActionFunctionArgs) {
|
||||
|
||||
switch (action) {
|
||||
case 'rename_tailnet':
|
||||
return renameTailnet(formData);
|
||||
return renameTailnet(formData, context);
|
||||
case 'toggle_magic':
|
||||
return toggleMagic(formData);
|
||||
return toggleMagic(formData, context);
|
||||
case 'remove_ns':
|
||||
return removeNs(formData);
|
||||
return removeNs(formData, context);
|
||||
case 'add_ns':
|
||||
return addNs(formData);
|
||||
return addNs(formData, context);
|
||||
case 'remove_domain':
|
||||
return removeDomain(formData);
|
||||
return removeDomain(formData, context);
|
||||
case 'add_domain':
|
||||
return addDomain(formData);
|
||||
return addDomain(formData, context);
|
||||
case 'remove_record':
|
||||
return removeRecord(formData);
|
||||
return removeRecord(formData, context);
|
||||
case 'add_record':
|
||||
return addRecord(formData);
|
||||
return addRecord(formData, context);
|
||||
default:
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameTailnet(formData: FormData) {
|
||||
async function renameTailnet(formData: FormData, context: LoadContext) {
|
||||
const newName = formData.get('new_name')?.toString();
|
||||
if (!newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.base_domain',
|
||||
value: newName,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function toggleMagic(formData: FormData) {
|
||||
async function toggleMagic(formData: FormData, context: LoadContext) {
|
||||
const newState = formData.get('new_state')?.toString();
|
||||
if (!newState) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.magic_dns',
|
||||
value: newState === 'enabled',
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function removeNs(formData: FormData) {
|
||||
async function removeNs(formData: FormData, context: LoadContext) {
|
||||
const config = context.hs.c!;
|
||||
const ns = formData.get('ns')?.toString();
|
||||
const splitName = formData.get('split_name')?.toString();
|
||||
|
||||
@ -82,15 +88,10 @@ async function removeNs(formData: FormData) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
if (splitName === 'global') {
|
||||
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.nameservers.global',
|
||||
value: servers,
|
||||
@ -100,7 +101,7 @@ async function removeNs(formData: FormData) {
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName].filter((i) => i !== ns);
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers,
|
||||
@ -108,10 +109,11 @@ async function removeNs(formData: FormData) {
|
||||
]);
|
||||
}
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function addNs(formData: FormData) {
|
||||
async function addNs(formData: FormData, context: LoadContext) {
|
||||
const config = context.hs.c!;
|
||||
const ns = formData.get('ns')?.toString();
|
||||
const splitName = formData.get('split_name')?.toString();
|
||||
|
||||
@ -119,16 +121,11 @@ async function addNs(formData: FormData) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
if (splitName === 'global') {
|
||||
const servers = config.dns.nameservers.global;
|
||||
servers.push(ns);
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.nameservers.global',
|
||||
value: servers,
|
||||
@ -139,7 +136,7 @@ async function addNs(formData: FormData) {
|
||||
const servers = splits[splitName] ?? [];
|
||||
servers.push(ns);
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers,
|
||||
@ -147,57 +144,49 @@ async function addNs(formData: FormData) {
|
||||
]);
|
||||
}
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function removeDomain(formData: FormData) {
|
||||
async function removeDomain(formData: FormData, context: LoadContext) {
|
||||
const config = context.hs.c!;
|
||||
const domain = formData.get('domain')?.toString();
|
||||
if (!domain) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
const domains = config.dns.search_domains.filter((i) => i !== domain);
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.search_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function addDomain(formData: FormData) {
|
||||
async function addDomain(formData: FormData, context: LoadContext) {
|
||||
const config = context.hs.c!;
|
||||
const domain = formData.get('domain')?.toString();
|
||||
if (!domain) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
const domains = config.dns.search_domains;
|
||||
domains.push(domain);
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.search_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function removeRecord(formData: FormData) {
|
||||
async function removeRecord(formData: FormData, context: LoadContext) {
|
||||
const config = context.hs.c!;
|
||||
const recordName = formData.get('record_name')?.toString();
|
||||
const recordType = formData.get('record_type')?.toString();
|
||||
|
||||
@ -205,26 +194,22 @@ async function removeRecord(formData: FormData) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
const records = config.dns.extra_records.filter(
|
||||
(i) => i.name !== recordName || i.type !== recordType,
|
||||
);
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.extra_records',
|
||||
value: records,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function addRecord(formData: FormData) {
|
||||
async function addRecord(formData: FormData, context: LoadContext) {
|
||||
const config = context.hs.c!;
|
||||
const recordName = formData.get('record_name')?.toString();
|
||||
const recordType = formData.get('record_type')?.toString();
|
||||
const recordValue = formData.get('record_value')?.toString();
|
||||
@ -233,20 +218,15 @@ async function addRecord(formData: FormData) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode !== 'rw') {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
const records = config.dns.extra_records;
|
||||
records.push({ name: recordName, type: recordType, value: recordValue });
|
||||
|
||||
await hs_patchConfig([
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.extra_records',
|
||||
value: records,
|
||||
},
|
||||
]);
|
||||
|
||||
await hp_getIntegration()?.onConfigChange();
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import type { ActionFunctionArgs } from 'react-router';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import Notice from '~/components/Notice';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import ManageDomains from './components/manage-domains';
|
||||
import ManageNS from './components/manage-ns';
|
||||
import ManageRecords from './components/manage-records';
|
||||
@ -11,12 +12,31 @@ import ToggleMagic from './components/toggle-magic';
|
||||
import { dnsAction } from './dns-actions';
|
||||
|
||||
// We do not want to expose every config value
|
||||
export async function loader() {
|
||||
const { config, mode } = hs_getConfig();
|
||||
if (mode === 'no') {
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
if (!context.hs.readable()) {
|
||||
throw new Error('No configuration is available');
|
||||
}
|
||||
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.read_network,
|
||||
);
|
||||
if (!check) {
|
||||
// Not authorized to view this page
|
||||
throw new Error(
|
||||
'You do not have permission to view this page. Please contact your administrator.',
|
||||
);
|
||||
}
|
||||
|
||||
const writablePermission = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_network,
|
||||
);
|
||||
|
||||
const config = context.hs.c!;
|
||||
const dns = {
|
||||
prefixes: config.prefixes,
|
||||
magicDns: config.dns.magic_dns,
|
||||
@ -29,7 +49,8 @@ export async function loader() {
|
||||
|
||||
return {
|
||||
...dns,
|
||||
mode,
|
||||
access: writablePermission,
|
||||
writable: context.hs.writable(),
|
||||
};
|
||||
}
|
||||
|
||||
@ -46,16 +67,22 @@ export default function Page() {
|
||||
}
|
||||
|
||||
allNs.global = data.nameservers;
|
||||
const isDisabled = data.mode !== 'rw';
|
||||
const isDisabled = data.access === false || data.writable === false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-16 max-w-screen-lg">
|
||||
{data.mode === 'rw' ? undefined : (
|
||||
{data.writable ? undefined : (
|
||||
<Notice>
|
||||
The Headscale configuration is read-only. You cannot make changes to
|
||||
the configuration
|
||||
</Notice>
|
||||
)}
|
||||
{data.access ? undefined : (
|
||||
<Notice>
|
||||
Your permissions do not allow you to modify the DNS settings for this
|
||||
tailnet.
|
||||
</Notice>
|
||||
)}
|
||||
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
|
||||
<ManageNS nameservers={allNs} isDisabled={isDisabled} />
|
||||
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
|
||||
|
||||
@ -1,212 +0,0 @@
|
||||
import type { ActionFunctionArgs } from 'react-router';
|
||||
import { del, post } from '~/utils/headscale';
|
||||
import { send } from '~/utils/res';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import log from '~server/utils/log';
|
||||
|
||||
export async function menuAction(request: ActionFunctionArgs['request']) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (!session.has('hsApiKey')) {
|
||||
return send(
|
||||
{ message: 'Unauthorized' },
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const data = await request.formData();
|
||||
if (!data.has('_method') || !data.has('id')) {
|
||||
return send(
|
||||
{ message: 'No method or ID provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const id = String(data.get('id'));
|
||||
const method = String(data.get('_method'));
|
||||
|
||||
switch (method) {
|
||||
case 'delete': {
|
||||
await del(`v1/node/${id}`, session.get('hsApiKey')!);
|
||||
return { message: 'Machine removed' };
|
||||
}
|
||||
|
||||
case 'expire': {
|
||||
await post(`v1/node/${id}/expire`, session.get('hsApiKey')!);
|
||||
return { message: 'Machine expired' };
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
if (!data.has('name')) {
|
||||
return send(
|
||||
{ message: 'No name provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const name = String(data.get('name'));
|
||||
|
||||
await post(`v1/node/${id}/rename/${name}`, session.get('hsApiKey')!);
|
||||
return { message: 'Machine renamed' };
|
||||
}
|
||||
|
||||
case 'routes': {
|
||||
if (!data.has('route') || !data.has('enabled')) {
|
||||
return send(
|
||||
{ message: 'No route or enabled provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const route = String(data.get('route'));
|
||||
const enabled = data.get('enabled') === 'true';
|
||||
const postfix = enabled ? 'enable' : 'disable';
|
||||
|
||||
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
|
||||
return { message: 'Route updated' };
|
||||
}
|
||||
|
||||
case 'exit-node': {
|
||||
if (!data.has('routes') || !data.has('enabled')) {
|
||||
return send(
|
||||
{ message: 'No route or enabled provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const routes = data.get('routes')?.toString().split(',') ?? [];
|
||||
const enabled = data.get('enabled') === 'true';
|
||||
const postfix = enabled ? 'enable' : 'disable';
|
||||
|
||||
await Promise.all(
|
||||
routes.map(async (route) => {
|
||||
await post(`v1/routes/${route}/${postfix}`, session.get('hsApiKey')!);
|
||||
}),
|
||||
);
|
||||
|
||||
return { message: 'Exit node updated' };
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
if (!data.has('to')) {
|
||||
return send(
|
||||
{ message: 'No destination provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const to = String(data.get('to'));
|
||||
|
||||
try {
|
||||
await post(`v1/node/${id}/user`, session.get('hsApiKey')!, {
|
||||
user: to,
|
||||
});
|
||||
|
||||
return { message: `Moved node ${id} to ${to}` };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return send(
|
||||
{ message: `Failed to move node ${id} to ${to}` },
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case 'tags': {
|
||||
const tags =
|
||||
data
|
||||
.get('tags')
|
||||
?.toString()
|
||||
.split(',')
|
||||
.filter((tag) => tag.trim() !== '') ?? [];
|
||||
|
||||
try {
|
||||
await post(`v1/node/${id}/tags`, session.get('hsApiKey')!, {
|
||||
tags,
|
||||
});
|
||||
|
||||
return { message: 'Tags updated' };
|
||||
} catch (error) {
|
||||
log.debug('APIC', 'Failed to update tags: %s', error);
|
||||
return send(
|
||||
{ message: 'Failed to update tags' },
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case 'register': {
|
||||
const key = data.get('mkey')?.toString();
|
||||
const user = data.get('user')?.toString();
|
||||
|
||||
if (!key) {
|
||||
return send(
|
||||
{ message: 'No machine key provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return send(
|
||||
{ message: 'No user provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const qp = new URLSearchParams();
|
||||
qp.append('user', user);
|
||||
qp.append('key', key);
|
||||
|
||||
const url = `v1/node/register?${qp.toString()}`;
|
||||
await post(url, session.get('hsApiKey')!, {
|
||||
user,
|
||||
key,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Machine registered',
|
||||
};
|
||||
} catch {
|
||||
return send(
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to register machine',
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return send(
|
||||
{ message: 'Invalid method' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,7 @@ interface Props {
|
||||
isAgent?: boolean;
|
||||
magic?: string;
|
||||
stats?: HostInfo;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function MachineRow({
|
||||
@ -27,6 +28,7 @@ export default function MachineRow({
|
||||
isAgent,
|
||||
magic,
|
||||
stats,
|
||||
isDisabled,
|
||||
}: Props) {
|
||||
const expired =
|
||||
machine.expiry === '0001-01-01 00:00:00' ||
|
||||
@ -96,7 +98,7 @@ export default function MachineRow({
|
||||
return (
|
||||
<tr
|
||||
key={machine.id}
|
||||
className="'group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
||||
>
|
||||
<td className="pl-0.5 py-2 focus-within:ring">
|
||||
<Link
|
||||
@ -152,18 +154,21 @@ export default function MachineRow({
|
||||
</Menu>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2">
|
||||
{stats !== undefined ? (
|
||||
<>
|
||||
<p className="leading-snug">{hinfo.getTSVersion(stats)}</p>
|
||||
<p className="text-sm opacity-50 max-w-48 truncate">
|
||||
{hinfo.getOSInfo(stats)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm opacity-50">Unknown</p>
|
||||
)}
|
||||
</td>
|
||||
{/* We pass undefined when agents are not enabled */}
|
||||
{isAgent !== undefined ? (
|
||||
<td className="py-2">
|
||||
{stats !== undefined ? (
|
||||
<>
|
||||
<p className="leading-snug">{hinfo.getTSVersion(stats)}</p>
|
||||
<p className="text-sm opacity-50 max-w-48 truncate">
|
||||
{hinfo.getOSInfo(stats)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-sm opacity-50">Unknown</p>
|
||||
)}
|
||||
</td>
|
||||
) : undefined}
|
||||
<td className="py-2">
|
||||
<span
|
||||
className={cn(
|
||||
@ -175,7 +180,7 @@ export default function MachineRow({
|
||||
isOnline={machine.online && !expired}
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
<p>
|
||||
<p suppressHydrationWarning>
|
||||
{machine.online && !expired
|
||||
? 'Connected'
|
||||
: new Date(machine.lastSeen).toLocaleString()}
|
||||
@ -188,6 +193,7 @@ export default function MachineRow({
|
||||
routes={routes}
|
||||
users={users}
|
||||
magic={magic}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@ -16,6 +16,7 @@ interface MenuProps {
|
||||
users: User[];
|
||||
magic?: string;
|
||||
isFullButton?: boolean;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
||||
@ -26,6 +27,7 @@ export default function MachineMenu({
|
||||
magic,
|
||||
users,
|
||||
isFullButton,
|
||||
isDisabled,
|
||||
}: MenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
|
||||
@ -96,7 +98,7 @@ export default function MachineMenu({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu>
|
||||
<Menu isDisabled={isDisabled}>
|
||||
{isFullButton ? (
|
||||
<Menu.Button className="flex items-center gap-x-2">
|
||||
<Cog className="h-5" />
|
||||
|
||||
@ -8,12 +8,13 @@ import Menu from '~/components/Menu';
|
||||
import Select from '~/components/Select';
|
||||
import type { User } from '~/types';
|
||||
|
||||
export interface NewProps {
|
||||
export interface NewMachineProps {
|
||||
server: string;
|
||||
users: User[];
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function New(data: NewProps) {
|
||||
export default function NewMachine(data: NewMachineProps) {
|
||||
const [pushDialog, setPushDialog] = useState(false);
|
||||
const [mkey, setMkey] = useState('');
|
||||
const navigate = useNavigate();
|
||||
@ -25,11 +26,8 @@ export default function New(data: NewProps) {
|
||||
<Dialog.Title>Register Machine Key</Dialog.Title>
|
||||
<Dialog.Text className="mb-4">
|
||||
The machine key is given when you run{' '}
|
||||
<Code isCopyable>
|
||||
tailscale up --login-server=
|
||||
{data.server}
|
||||
</Code>{' '}
|
||||
on your device.
|
||||
<Code isCopyable>tailscale up --login-server={data.server}</Code> on
|
||||
your device.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="_method" value="register" />
|
||||
<input type="hidden" name="id" value="_" />
|
||||
@ -53,7 +51,7 @@ export default function New(data: NewProps) {
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
<Menu>
|
||||
<Menu isDisabled={data.isDisabled}>
|
||||
<Menu.Button variant="heavy">Add Device</Menu.Button>
|
||||
<Menu.Panel
|
||||
onAction={(key) => {
|
||||
|
||||
247
app/routes/machines/machine-actions.ts
Normal file
247
app/routes/machines/machine-actions.ts
Normal file
@ -0,0 +1,247 @@
|
||||
import type { ActionFunctionArgs } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { Machine } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import { data400, data403, data404, send } from '~/utils/res';
|
||||
|
||||
// TODO: Clean this up like dns-actions and user-actions
|
||||
export async function machineAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_machines,
|
||||
);
|
||||
|
||||
const apiKey = session.get('api_key')!;
|
||||
const formData = await request.formData();
|
||||
|
||||
// TODO: Rename this to 'action_id' and 'node_id'
|
||||
const action = formData.get('_method')?.toString();
|
||||
const nodeId = formData.get('id')?.toString();
|
||||
if (!action || !nodeId) {
|
||||
return data400('Missing required parameters: _method and id');
|
||||
}
|
||||
|
||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const node = nodes.find((node) => node.id === nodeId);
|
||||
if (!node) {
|
||||
return data404(`Node with ID ${nodeId} not found`);
|
||||
}
|
||||
|
||||
const subject = session.get('user')!.subject;
|
||||
if (node.user.providerId?.split('/').pop() !== subject) {
|
||||
if (!check) {
|
||||
return data403('You do not have permission to act on this machine');
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Split up into methods
|
||||
switch (action) {
|
||||
case 'delete': {
|
||||
await context.client.delete(`v1/node/${nodeId}`, session.get('api_key')!);
|
||||
return { message: 'Machine removed' };
|
||||
}
|
||||
|
||||
case 'expire': {
|
||||
await context.client.post(
|
||||
`v1/node/${nodeId}/expire`,
|
||||
session.get('api_key')!,
|
||||
);
|
||||
return { message: 'Machine expired' };
|
||||
}
|
||||
|
||||
case 'rename': {
|
||||
if (!formData.has('name')) {
|
||||
return send(
|
||||
{ message: 'No name provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const name = String(formData.get('name'));
|
||||
await context.client.post(
|
||||
`v1/node/${nodeId}/rename/${name}`,
|
||||
session.get('api_key')!,
|
||||
);
|
||||
return { message: 'Machine renamed' };
|
||||
}
|
||||
|
||||
case 'routes': {
|
||||
if (!formData.has('route') || !formData.has('enabled')) {
|
||||
return send(
|
||||
{ message: 'No route or enabled provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const route = String(formData.get('route'));
|
||||
const enabled = formData.get('enabled') === 'true';
|
||||
const postfix = enabled ? 'enable' : 'disable';
|
||||
|
||||
await context.client.post(
|
||||
`v1/routes/${route}/${postfix}`,
|
||||
session.get('api_key')!,
|
||||
);
|
||||
return { message: 'Route updated' };
|
||||
}
|
||||
|
||||
case 'exit-node': {
|
||||
if (!formData.has('routes') || !formData.has('enabled')) {
|
||||
return send(
|
||||
{ message: 'No route or enabled provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const routes = formData.get('routes')?.toString().split(',') ?? [];
|
||||
const enabled = formData.get('enabled') === 'true';
|
||||
const postfix = enabled ? 'enable' : 'disable';
|
||||
|
||||
await Promise.all(
|
||||
routes.map(async (route) => {
|
||||
await context.client.post(
|
||||
`v1/routes/${route}/${postfix}`,
|
||||
session.get('api_key')!,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return { message: 'Exit node updated' };
|
||||
}
|
||||
|
||||
case 'move': {
|
||||
if (!formData.has('to')) {
|
||||
return send(
|
||||
{ message: 'No destination provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const to = String(formData.get('to'));
|
||||
|
||||
try {
|
||||
await context.client.post(
|
||||
`v1/node/${nodeId}/user`,
|
||||
session.get('api_key')!,
|
||||
{
|
||||
user: to,
|
||||
},
|
||||
);
|
||||
|
||||
return { message: `Moved node ${nodeId} to ${to}` };
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
return send(
|
||||
{ message: `Failed to move node ${nodeId} to ${to}` },
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case 'tags': {
|
||||
const tags =
|
||||
formData
|
||||
.get('tags')
|
||||
?.toString()
|
||||
.split(',')
|
||||
.filter((tag) => tag.trim() !== '') ?? [];
|
||||
|
||||
try {
|
||||
await context.client.post(
|
||||
`v1/node/${nodeId}/tags`,
|
||||
session.get('api_key')!,
|
||||
{
|
||||
tags,
|
||||
},
|
||||
);
|
||||
|
||||
return { message: 'Tags updated' };
|
||||
} catch (error) {
|
||||
log.debug('api', 'Failed to update tags: %s', error);
|
||||
return send(
|
||||
{ message: 'Failed to update tags' },
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case 'register': {
|
||||
const key = formData.get('mkey')?.toString();
|
||||
const user = formData.get('user')?.toString();
|
||||
|
||||
if (!key) {
|
||||
return send(
|
||||
{ message: 'No machine key provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) {
|
||||
return send(
|
||||
{ message: 'No user provided' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const qp = new URLSearchParams();
|
||||
qp.append('user', user);
|
||||
qp.append('key', key);
|
||||
|
||||
const url = `v1/node/register?${qp.toString()}`;
|
||||
await context.client.post(url, session.get('api_key')!, {
|
||||
user,
|
||||
key,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Machine registered',
|
||||
};
|
||||
} catch {
|
||||
return send(
|
||||
{
|
||||
success: false,
|
||||
message: 'Failed to register machine',
|
||||
},
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
default: {
|
||||
return send(
|
||||
{ message: 'Invalid method' },
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,35 +9,40 @@ import Chip from '~/components/Chip';
|
||||
import Link from '~/components/Link';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import { pull } from '~/utils/headscale';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import { hp_getSingleton } from '~server/context/global';
|
||||
import { menuAction } from './action';
|
||||
import MenuOptions from './components/menu';
|
||||
import Routes from './dialogs/routes';
|
||||
import { machineAction } from './machine-actions';
|
||||
|
||||
export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
export async function loader({
|
||||
request,
|
||||
params,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (!params.id) {
|
||||
throw new Error('No machine ID provided');
|
||||
}
|
||||
|
||||
const { mode, config } = hs_getConfig();
|
||||
let magic: string | undefined;
|
||||
|
||||
if (mode !== 'no') {
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
|
||||
const [machine, routes, users] = await Promise.all([
|
||||
pull<{ node: Machine }>(`v1/node/${params.id}`, session.get('hsApiKey')!),
|
||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
context.client.get<{ node: Machine }>(
|
||||
`v1/node/${params.id}`,
|
||||
session.get('api_key')!,
|
||||
),
|
||||
context.client.get<{ routes: Route[] }>(
|
||||
'v1/routes',
|
||||
session.get('api_key')!,
|
||||
),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
||||
]);
|
||||
|
||||
return {
|
||||
@ -45,12 +50,16 @@ export async function loader({ request, params }: LoaderFunctionArgs) {
|
||||
routes: routes.routes.filter((route) => route.node.id === params.id),
|
||||
users: users.users,
|
||||
magic,
|
||||
agent: [...hp_getSingleton('ws_agents').keys()].includes(machine.node.id),
|
||||
// TODO: Fix agent
|
||||
agent: false,
|
||||
// agent: [...(hp_getSingletonUnsafe('ws_agents') ?? []).keys()].includes(
|
||||
// machine.node.id,
|
||||
// ),
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
return menuAction(request);
|
||||
export async function action(request: ActionFunctionArgs) {
|
||||
return machineAction(request);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
@ -301,14 +310,17 @@ export default function Page() {
|
||||
<Attribute name="Hostname" value={machine.name} />
|
||||
<Attribute isCopyable name="Node Key" value={machine.nodeKey} />
|
||||
<Attribute
|
||||
suppressHydrationWarning
|
||||
name="Created"
|
||||
value={new Date(machine.createdAt).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
suppressHydrationWarning
|
||||
name="Last Seen"
|
||||
value={new Date(machine.lastSeen).toLocaleString()}
|
||||
/>
|
||||
<Attribute
|
||||
suppressHydrationWarning
|
||||
name="Expiry"
|
||||
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
|
||||
/>
|
||||
|
||||
@ -1,38 +1,61 @@
|
||||
import { InfoIcon } from '@primer/octicons-react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
|
||||
import Code from '~/components/Code';
|
||||
import { ErrorPopup } from '~/components/Error';
|
||||
import Link from '~/components/Link';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Machine, Route, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { pull } from '~/utils/headscale';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import useAgent from '~/utils/useAgent';
|
||||
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
|
||||
import { menuAction } from './action';
|
||||
import MachineRow from './components/machine';
|
||||
import MachineRow from './components/machine-row';
|
||||
import NewMachine from './dialogs/new';
|
||||
import { machineAction } from './machine-actions';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const user = session.get('user');
|
||||
if (!user) {
|
||||
throw new Error('Missing user session. Please log in again.');
|
||||
}
|
||||
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.read_machines,
|
||||
);
|
||||
|
||||
if (!check) {
|
||||
// Not authorized to view this page
|
||||
throw new Error(
|
||||
'You do not have permission to view this page. Please contact your administrator.',
|
||||
);
|
||||
}
|
||||
|
||||
const writablePermission = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_machines,
|
||||
);
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
const [machines, routes, users] = await Promise.all([
|
||||
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
||||
pull<{ routes: Route[] }>('v1/routes', session.get('hsApiKey')!),
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.get('api_key')!,
|
||||
),
|
||||
context.client.get<{ routes: Route[] }>(
|
||||
'v1/routes',
|
||||
session.get('api_key')!,
|
||||
),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
||||
]);
|
||||
|
||||
const context = hp_getConfig();
|
||||
const { mode, config } = hs_getConfig();
|
||||
let magic: string | undefined;
|
||||
|
||||
if (mode !== 'no') {
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
|
||||
@ -41,19 +64,21 @@ export async function loader({ request }: LoaderFunctionArgs) {
|
||||
routes: routes.routes,
|
||||
users: users.users,
|
||||
magic,
|
||||
server: context.headscale.url,
|
||||
publicServer: context.headscale.public_url,
|
||||
agents: [...hp_getSingleton('ws_agents').keys()],
|
||||
server: context.config.headscale.url,
|
||||
publicServer: context.config.headscale.public_url,
|
||||
agents: context.agents?.tailnetIDs(),
|
||||
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
|
||||
writable: writablePermission,
|
||||
subject: user.subject,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
return menuAction(request);
|
||||
export async function action(request: ActionFunctionArgs) {
|
||||
return machineAction(request);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const { data: stats } = useAgent(data.nodes.map((node) => node.nodeKey));
|
||||
|
||||
return (
|
||||
<>
|
||||
@ -73,6 +98,7 @@ export default function Page() {
|
||||
<NewMachine
|
||||
server={data.publicServer ?? data.server}
|
||||
users={data.users}
|
||||
isDisabled={!data.writable}
|
||||
/>
|
||||
</div>
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
@ -97,7 +123,10 @@ export default function Page() {
|
||||
) : undefined}
|
||||
</div>
|
||||
</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Version</th>
|
||||
{/* We only want to show the version column if there are agents */}
|
||||
{data.agents !== undefined ? (
|
||||
<th className="uppercase text-xs font-bold pb-2">Version</th>
|
||||
) : undefined}
|
||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -116,8 +145,15 @@ export default function Page() {
|
||||
)}
|
||||
users={data.users}
|
||||
magic={data.magic}
|
||||
stats={stats?.[machine.nodeKey]}
|
||||
isAgent={data.agents.includes(machine.id)}
|
||||
// If we pass undefined, the column will not be rendered
|
||||
// This is useful for when there are no agents configured
|
||||
isAgent={data.agents?.includes(machine.id)}
|
||||
stats={data.stats?.[machine.nodeKey]}
|
||||
isDisabled={
|
||||
data.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
: machine.user.providerId?.split('/').pop() !== data.subject
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@ -5,52 +5,48 @@ import { Link as RemixLink } from 'react-router';
|
||||
import Link from '~/components/Link';
|
||||
import Select from '~/components/Select';
|
||||
import TableList from '~/components/TableList';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { PreAuthKey, User } from '~/types';
|
||||
import { post, pull } from '~/utils/headscale';
|
||||
import { send } from '~/utils/res';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
import { hp_getConfig } from '~server/context/global';
|
||||
import AuthKeyRow from './components/key';
|
||||
import AddPreAuthKey from './dialogs/new';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
const users = await pull<{ users: User[] }>(
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const users = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.get('hsApiKey')!,
|
||||
session.get('api_key')!,
|
||||
);
|
||||
|
||||
const context = hp_getConfig();
|
||||
const preAuthKeys = await Promise.all(
|
||||
users.users.map((user) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.set('user', user.name);
|
||||
users.users
|
||||
.filter((user) => user.name?.length > 0) // Filter out any invalid users
|
||||
.map((user) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.set('user', user.name);
|
||||
|
||||
return pull<{ preAuthKeys: PreAuthKey[] }>(
|
||||
`v1/preauthkey?${qp.toString()}`,
|
||||
session.get('hsApiKey')!,
|
||||
);
|
||||
}),
|
||||
return context.client.get<{ preAuthKeys: PreAuthKey[] }>(
|
||||
`v1/preauthkey?${qp.toString()}`,
|
||||
session.get('api_key')!,
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
keys: preAuthKeys.flatMap((keys) => keys.preAuthKeys),
|
||||
users: users.users,
|
||||
server: context.headscale.public_url ?? context.headscale.url,
|
||||
server: context.config.headscale.public_url ?? context.config.headscale.url,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action({ request }: ActionFunctionArgs) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
if (!session.has('hsApiKey')) {
|
||||
return send(
|
||||
{ message: 'Unauthorized' },
|
||||
{
|
||||
status: 401,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const data = await request.formData();
|
||||
|
||||
// Expiring a pre-auth key
|
||||
@ -67,9 +63,9 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
);
|
||||
}
|
||||
|
||||
await post<{ preAuthKey: PreAuthKey }>(
|
||||
await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey/expire',
|
||||
session.get('hsApiKey')!,
|
||||
session.get('api_key')!,
|
||||
{
|
||||
user: user,
|
||||
key: key,
|
||||
@ -101,9 +97,9 @@ export async function action({ request }: ActionFunctionArgs) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + day);
|
||||
|
||||
const key = await post<{ preAuthKey: PreAuthKey }>(
|
||||
const key = await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey',
|
||||
session.get('hsApiKey')!,
|
||||
session.get('api_key')!,
|
||||
{
|
||||
user: user,
|
||||
ephemeral: ephemeral === 'on',
|
||||
|
||||
@ -22,15 +22,19 @@ export default function AuthKeyRow({ authKey, server }: Props) {
|
||||
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
|
||||
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
|
||||
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
|
||||
<Attribute name="Created" value={createdAt} />
|
||||
<Attribute name="Expiration" value={expiration} />
|
||||
<Attribute suppressHydrationWarning name="Created" value={createdAt} />
|
||||
<Attribute
|
||||
suppressHydrationWarning
|
||||
name="Expiration"
|
||||
value={expiration}
|
||||
/>
|
||||
<p className="mb-1 mt-4">
|
||||
To use this key, run the following command on your device:
|
||||
</p>
|
||||
<Code className="text-sm">
|
||||
tailscale up --login-server {server} --authkey {authKey.key}
|
||||
</Code>
|
||||
<div className="flex gap-4 items-center">
|
||||
<div suppressHydrationWarning className="flex gap-4 items-center">
|
||||
{(authKey.used && !authKey.reusable) ||
|
||||
new Date(authKey.expiration) < new Date() ? undefined : (
|
||||
<ExpireKey authKey={authKey} />
|
||||
|
||||
@ -1,14 +1,15 @@
|
||||
import { Building2, House, Key } from 'lucide-react';
|
||||
import Card from '~/components/Card';
|
||||
import Link from '~/components/Link';
|
||||
import type { HeadplaneConfig } from '~server/context/parser';
|
||||
import type { HeadplaneConfig } from '~/server/config/schema';
|
||||
import CreateUser from '../dialogs/create-user';
|
||||
|
||||
interface Props {
|
||||
interface ManageBannerProps {
|
||||
oidc?: NonNullable<HeadplaneConfig['oidc']>;
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ManageBanner({ oidc }: Props) {
|
||||
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
|
||||
return (
|
||||
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
@ -60,7 +61,7 @@ export default function ManageBanner({ oidc }: Props) {
|
||||
: 'You can add, remove, and rename users here.'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<CreateUser />
|
||||
<CreateUser isDisabled={isDisabled} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
74
app/routes/users/components/menu.tsx
Normal file
74
app/routes/users/components/menu.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import { Ellipsis } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import Menu from '~/components/Menu';
|
||||
import type { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import Delete from '../dialogs/delete-user';
|
||||
import Reassign from '../dialogs/reassign-user';
|
||||
import Rename from '../dialogs/rename-user';
|
||||
|
||||
interface MenuProps {
|
||||
user: User & {
|
||||
headplaneRole: string;
|
||||
machines: Machine[];
|
||||
};
|
||||
}
|
||||
|
||||
type Modal = 'rename' | 'delete' | 'reassign' | null;
|
||||
|
||||
export default function UserMenu({ user }: MenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
return (
|
||||
<>
|
||||
{modal === 'rename' && (
|
||||
<Rename
|
||||
user={user}
|
||||
isOpen={modal === 'rename'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modal === 'delete' && (
|
||||
<Delete
|
||||
user={user}
|
||||
isOpen={modal === 'delete'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{modal === 'reassign' && (
|
||||
<Reassign
|
||||
user={user}
|
||||
isOpen={modal === 'reassign'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : []}>
|
||||
<Menu.IconButton
|
||||
label="Machine Options"
|
||||
className={cn(
|
||||
'py-0.5 w-10 bg-transparent border-transparent',
|
||||
'border group-hover:border-headplane-200',
|
||||
'dark:group-hover:border-headplane-700',
|
||||
)}
|
||||
>
|
||||
<Ellipsis className="h-5" />
|
||||
</Menu.IconButton>
|
||||
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="rename">Rename user</Menu.Item>
|
||||
<Menu.Item key="reassign">Change role</Menu.Item>
|
||||
<Menu.Item key="delete" textValue="Delete">
|
||||
<p className="text-red-500 dark:text-red-400">Delete</p>
|
||||
</Menu.Item>
|
||||
</Menu.Section>
|
||||
</Menu.Panel>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
95
app/routes/users/components/user-row.tsx
Normal file
95
app/routes/users/components/user-row.tsx
Normal file
@ -0,0 +1,95 @@
|
||||
import { CircleUser } from 'lucide-react';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import MenuOptions from './menu';
|
||||
|
||||
interface UserRowProps {
|
||||
role: string;
|
||||
user: User & { machines: Machine[] };
|
||||
}
|
||||
|
||||
export default function UserRow({ user, role }: UserRowProps) {
|
||||
const isOnline = user.machines.some((machine) => machine.online);
|
||||
const lastSeen = user.machines.reduce(
|
||||
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={user.id}
|
||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
||||
>
|
||||
<td className="pl-0.5 py-2">
|
||||
<div className="flex items-center">
|
||||
{user.profilePicUrl ? (
|
||||
<img
|
||||
src={user.profilePicUrl}
|
||||
alt={user.name}
|
||||
className="w-10 h-10 rounded-full"
|
||||
/>
|
||||
) : (
|
||||
<CircleUser className="w-10 h-10" />
|
||||
)}
|
||||
<div className="ml-4">
|
||||
<p className={cn('font-semibold leading-snug')}>{user.name}</p>
|
||||
<p className="text-sm opacity-50">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="pl-0.5 py-2">
|
||||
<p>{mapRoleToName(role)}</p>
|
||||
</td>
|
||||
<td className="pl-0.5 py-2">
|
||||
<p
|
||||
suppressHydrationWarning
|
||||
className="text-sm text-headplane-600 dark:text-headplane-300"
|
||||
>
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
</td>
|
||||
<td className="pl-0.5 py-2">
|
||||
<span
|
||||
className={cn(
|
||||
'flex items-center gap-x-1 text-sm',
|
||||
'text-headplane-600 dark:text-headplane-300',
|
||||
)}
|
||||
>
|
||||
<StatusCircle isOnline={isOnline} className="w-4 h-4" />
|
||||
<p suppressHydrationWarning>
|
||||
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
|
||||
</p>
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions user={{ ...user, headplaneRole: role }} />
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function mapRoleToName(role: string) {
|
||||
switch (role) {
|
||||
case 'no-oidc':
|
||||
return <p className="opacity-50">Unmanaged</p>;
|
||||
case 'invalid-oidc':
|
||||
return <p className="opacity-50">Invalid</p>;
|
||||
case 'no-role':
|
||||
return <p className="opacity-50">Unregistered</p>;
|
||||
case 'owner':
|
||||
return 'Owner';
|
||||
case 'admin':
|
||||
return 'Admin';
|
||||
case 'network_admin':
|
||||
return 'Network Admin';
|
||||
case 'it_admin':
|
||||
return 'IT Admin';
|
||||
case 'auditor':
|
||||
return 'Auditor';
|
||||
case 'member':
|
||||
return 'Member';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
@ -1,11 +1,15 @@
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
|
||||
interface CreateUserProps {
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
// TODO: Support image upload for user avatars
|
||||
export default function CreateUser() {
|
||||
export default function CreateUser({ isDisabled }: CreateUserProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.Button>Add a new user</Dialog.Button>
|
||||
<Dialog.Button isDisabled={isDisabled}>Add a new user</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Add a new user</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
|
||||
@ -1,27 +1,38 @@
|
||||
import { X } from 'lucide-react';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import { User } from '~/types';
|
||||
import { Machine, User } from '~/types';
|
||||
|
||||
interface Props {
|
||||
user: User;
|
||||
interface DeleteProps {
|
||||
user: User & { machines: Machine[] };
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
// TODO: Warn that OIDC users will be recreated on next login
|
||||
export default function DeleteUser({ user }: Props) {
|
||||
export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
|
||||
const name =
|
||||
(user.displayName?.length ?? 0) > 0 ? user.displayName : user.name;
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.IconButton label={`Delete ${name}`}>
|
||||
<X className="p-0.5" />
|
||||
</Dialog.IconButton>
|
||||
<Dialog.Panel>
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel
|
||||
variant={user.machines.length > 0 ? 'unactionable' : 'normal'}
|
||||
>
|
||||
<Dialog.Title>Delete {name}?</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
Are you sure you want to delete {name}? A deleted user cannot be
|
||||
recovered.
|
||||
</Dialog.Text>
|
||||
{user.machines.length > 0 ? (
|
||||
<Dialog.Text className="mb-6">
|
||||
Users cannot be deleted if they have machines. Please delete or
|
||||
re-assign their machines to other users before proceeding.
|
||||
</Dialog.Text>
|
||||
) : (
|
||||
<Dialog.Text className="mb-6">
|
||||
Deleted users cannot be recovered.
|
||||
{user.provider === 'oidc' && (
|
||||
<p className="mt-4 text-sm text-headplane-600 dark:text-headplane-300">
|
||||
Since this user is authenticated via an external provider, they
|
||||
will be recreated if they sign in again.
|
||||
</p>
|
||||
)}
|
||||
</Dialog.Text>
|
||||
)}
|
||||
<input type="hidden" name="action_id" value="delete_user" />
|
||||
<input type="hidden" name="user_id" value={user.id} />
|
||||
</Dialog.Panel>
|
||||
|
||||
103
app/routes/users/dialogs/reassign-user.tsx
Normal file
103
app/routes/users/dialogs/reassign-user.tsx
Normal file
@ -0,0 +1,103 @@
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import RadioGroup from '~/components/RadioGroup';
|
||||
import { Roles } from '~/server/web/roles';
|
||||
import { User } from '~/types';
|
||||
|
||||
interface ReassignProps {
|
||||
user: User & { headplaneRole: string };
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function ReassignUser({
|
||||
user,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
}: ReassignProps) {
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel
|
||||
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
|
||||
>
|
||||
<Dialog.Title>Change role for {user.name}?</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
Most roles are carried straight from Tailscale. However, keep in mind
|
||||
that I have not fully implemented permissions yet and some things may
|
||||
be accessible to everyone. The only fully completed role is Member.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1138/user-roles"
|
||||
name="Tailscale User Roles documentation"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
{user.headplaneRole === 'owner' ? (
|
||||
<Notice>The Tailnet owner cannot be reassigned.</Notice>
|
||||
) : (
|
||||
<>
|
||||
<input type="hidden" name="action_id" value="reassign_user" />
|
||||
<input type="hidden" name="user_id" value={user.id} />
|
||||
<RadioGroup
|
||||
isRequired
|
||||
name="new_role"
|
||||
label="Role"
|
||||
className="gap-4"
|
||||
defaultValue={user.headplaneRole}
|
||||
>
|
||||
{Object.keys(Roles)
|
||||
.filter((role) => role !== 'owner')
|
||||
.map((role) => {
|
||||
const { name, desc } = mapRoleToName(role);
|
||||
return (
|
||||
<RadioGroup.Radio key={role} value={role} label={name}>
|
||||
<div className="block">
|
||||
<p className="font-bold">{name}</p>
|
||||
<p className="opacity-70">{desc}</p>
|
||||
</div>
|
||||
</RadioGroup.Radio>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</>
|
||||
)}
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function mapRoleToName(role: string) {
|
||||
switch (role) {
|
||||
case 'admin':
|
||||
return {
|
||||
name: 'Admin',
|
||||
desc: 'Can view the admin console, manage network, machine, and user settings.',
|
||||
};
|
||||
case 'network_admin':
|
||||
return {
|
||||
name: 'Network Admin',
|
||||
desc: 'Can view the admin console and manage ACLs and network settings. Cannot manage machines or users.',
|
||||
};
|
||||
case 'it_admin':
|
||||
return {
|
||||
name: 'IT Admin',
|
||||
desc: 'Can view the admin console and manage machines and users. Cannot manage ACLs or network settings.',
|
||||
};
|
||||
case 'auditor':
|
||||
return {
|
||||
name: 'Auditor',
|
||||
desc: 'Can view the admin console.',
|
||||
};
|
||||
case 'member':
|
||||
return {
|
||||
name: 'Member',
|
||||
desc: 'Cannot view the admin console.',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
name: 'Unknown',
|
||||
desc: 'Unknown',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,19 +1,17 @@
|
||||
import { Pencil } from 'lucide-react';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
import { User } from '~/types';
|
||||
|
||||
interface Props {
|
||||
interface RenameProps {
|
||||
user: User;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
// TODO: Server side validation before submitting
|
||||
export default function RenameUser({ user }: Props) {
|
||||
export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
|
||||
return (
|
||||
<Dialog>
|
||||
<Dialog.IconButton label={`Rename ${user.name}`}>
|
||||
<Pencil className="p-1" />
|
||||
</Dialog.IconButton>
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Rename {user.name}?</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
|
||||
16
app/routes/users/onboarding-skip.tsx
Normal file
16
app/routes/users/onboarding-skip.tsx
Normal file
@ -0,0 +1,16 @@
|
||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const user = session.get('user');
|
||||
if (!user) {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
context.sessions.overrideOnboarding(user.subject, true);
|
||||
return redirect('/machines');
|
||||
}
|
||||
347
app/routes/users/onboarding.tsx
Normal file
347
app/routes/users/onboarding.tsx
Normal file
@ -0,0 +1,347 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import { useEffect } from 'react';
|
||||
import { GrApple } from 'react-icons/gr';
|
||||
import { ImFinder } from 'react-icons/im';
|
||||
import { MdAndroid } from 'react-icons/md';
|
||||
import { PiTerminalFill, PiWindowsLogoFill } from 'react-icons/pi';
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
NavLink,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Link from '~/components/Link';
|
||||
import Options from '~/components/Options';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import { LoadContext } from '~/server';
|
||||
import { Machine } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import log from '~/utils/log';
|
||||
import toast from '~/utils/toast';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const user = session.get('user');
|
||||
if (!user) {
|
||||
return redirect('/login');
|
||||
}
|
||||
|
||||
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
|
||||
// We need to convert this to a known value to return it to the client so we can
|
||||
// automatically tab to the correct download button.
|
||||
const userAgent = request.headers.get('user-agent');
|
||||
const os = userAgent?.match(/(Linux|Windows|Mac OS X|iPhone|iPad|Android)/);
|
||||
let osValue = 'linux';
|
||||
switch (os?.[0]) {
|
||||
case 'Windows':
|
||||
osValue = 'windows';
|
||||
break;
|
||||
case 'Mac OS X':
|
||||
osValue = 'macos';
|
||||
break;
|
||||
|
||||
case 'iPhone':
|
||||
case 'iPad':
|
||||
osValue = 'ios';
|
||||
break;
|
||||
|
||||
case 'Android':
|
||||
osValue = 'android';
|
||||
break;
|
||||
|
||||
default:
|
||||
osValue = 'linux';
|
||||
break;
|
||||
}
|
||||
|
||||
let firstMachine: Machine | undefined = undefined;
|
||||
try {
|
||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.get('api_key')!,
|
||||
);
|
||||
|
||||
const node = nodes.find((n) => {
|
||||
if (n.user.provider !== 'oidc') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = n.user.providerId?.split('/').pop();
|
||||
if (!subject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sessionUser = session.get('user');
|
||||
if (!sessionUser) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (subject !== sessionUser.subject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
firstMachine = node;
|
||||
} catch (e) {
|
||||
// If we cannot lookup nodes, we cannot proceed
|
||||
log.debug('api', 'Failed to lookup nodes %o', e);
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
osValue,
|
||||
firstMachine,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { user, osValue, firstMachine } = useLoaderData<typeof loader>();
|
||||
const { pause, resume } = useLiveData();
|
||||
useEffect(() => {
|
||||
if (firstMachine) {
|
||||
pause();
|
||||
} else {
|
||||
resume();
|
||||
}
|
||||
}, [firstMachine]);
|
||||
|
||||
const subject = user.email ? (
|
||||
<>
|
||||
as <strong>{user.email}</strong>
|
||||
</>
|
||||
) : (
|
||||
'with your OIDC provider'
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="fixed w-full h-screen flex items-center px-4">
|
||||
<div className="w-fit mx-auto grid grid-cols-1 md:grid-cols-2 gap-4 mb-24">
|
||||
<Card variant="flat" className="max-w-lg">
|
||||
<Card.Title className="mb-8">
|
||||
Welcome!
|
||||
<br />
|
||||
Let's get set up
|
||||
</Card.Title>
|
||||
<Card.Text>
|
||||
Install Tailscale and sign in {subject}. Once you sign in on a
|
||||
device, it will be automatically added to your Headscale network.
|
||||
</Card.Text>
|
||||
|
||||
<Options
|
||||
defaultSelectedKey={osValue}
|
||||
label="Download Selector"
|
||||
className="my-4"
|
||||
>
|
||||
<Options.Item
|
||||
key="linux"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<PiTerminalFill className="ml-1 w-4" />
|
||||
<span>Linux</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="flex text-md font-mono"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
'curl -fsSL https://tailscale.com/install.sh | sh',
|
||||
);
|
||||
|
||||
toast('Copied to clipboard');
|
||||
}}
|
||||
>
|
||||
curl -fsSL https://tailscale.com/install.sh | sh
|
||||
</Button>
|
||||
<p className="text-xs mt-1 text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Click this button to copy the command.{' '}
|
||||
<Link
|
||||
name="Linux installation script"
|
||||
to="https://github.com/tailscale/tailscale/blob/main/scripts/installer.sh"
|
||||
>
|
||||
View script source
|
||||
</Link>
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="windows"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<PiWindowsLogoFill className="ml-1 w-4" />
|
||||
<span>Windows</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
|
||||
aria-label="Download for Windows"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button variant="heavy" className="my-4 w-full">
|
||||
Download for Windows
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Requires Windows 10 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="macos"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<ImFinder className="ml-1 w-4" />
|
||||
<span>macOS</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
|
||||
aria-label="Download for macOS"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button variant="heavy" className="my-4 w-full">
|
||||
Download for macOS
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Requires macOS Big Sur 11.0 or later.
|
||||
<br />
|
||||
You can also download Tailscale on the{' '}
|
||||
<Link
|
||||
name="macOS App Store"
|
||||
to="https://apps.apple.com/ca/app/tailscale/id1475387142"
|
||||
>
|
||||
macOS App Store
|
||||
</Link>
|
||||
{'.'}
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="ios"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<GrApple className="ml-1 w-4" />
|
||||
<span>iOS</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
href="https://apps.apple.com/us/app/tailscale/id1470499037"
|
||||
aria-label="Download for iOS"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button variant="heavy" className="my-4 w-full">
|
||||
Download for iOS
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Requires iOS 15 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
<Options.Item
|
||||
key="android"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<MdAndroid className="ml-1 w-4" />
|
||||
<span>Android</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<a
|
||||
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
|
||||
aria-label="Download for Android"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<Button variant="heavy" className="my-4 w-full">
|
||||
Download for Android
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-headplane-600 dark:text-headplane-300 text-center">
|
||||
Requires Android 8 or later.
|
||||
</p>
|
||||
</Options.Item>
|
||||
</Options>
|
||||
</Card>
|
||||
<Card variant="flat">
|
||||
{firstMachine ? (
|
||||
<div className="flex flex-col justify-between h-full">
|
||||
<Card.Title className="mb-8">
|
||||
Success!
|
||||
<br />
|
||||
We found your first device
|
||||
</Card.Title>
|
||||
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<StatusCircle
|
||||
isOnline={firstMachine.online}
|
||||
className="size-6 mt-3"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold leading-snug">
|
||||
{firstMachine.givenName}
|
||||
</p>
|
||||
<p className="text-sm font-mono opacity-50">
|
||||
{firstMachine.name}
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<p className="text-sm font-semibold">IP Addresses</p>
|
||||
{firstMachine.ipAddresses.map((ip) => (
|
||||
<p key={ip} className="text-xs font-mono opacity-50">
|
||||
{ip}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NavLink to="/">
|
||||
<Button variant="heavy" className="w-full">
|
||||
Continue
|
||||
</Button>
|
||||
</NavLink>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 h-full">
|
||||
<span className="relative flex size-4">
|
||||
<span
|
||||
className={cn(
|
||||
'absolute inline-flex h-full w-full',
|
||||
'rounded-full opacity-75 animate-ping',
|
||||
'bg-headplane-500',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex size-4 rounded-full',
|
||||
'bg-headplane-400',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
<p className="font-lg">Waiting for your first device...</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
<NavLink to="/onboarding/skip" className="col-span-2 w-max mx-auto">
|
||||
<Button className="flex items-center gap-1">
|
||||
I already know what I'm doing
|
||||
<ArrowRight className="p-1" />
|
||||
</Button>
|
||||
</NavLink>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,32 +1,42 @@
|
||||
import { DataRef, DndContext, useDraggable, useDroppable } from '@dnd-kit/core';
|
||||
import { PersonIcon } from '@primer/octicons-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData, useSubmit } from 'react-router';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
|
||||
import Attribute from '~/components/Attribute';
|
||||
import Card from '~/components/Card';
|
||||
import { ErrorPopup } from '~/components/Error';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import type { Machine, User } from '~/types';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { pull } from '~/utils/headscale';
|
||||
import { getSession } from '~/utils/sessions.server';
|
||||
|
||||
import { hs_getConfig } from '~/utils/config/loader';
|
||||
import type { AppContext } from '~server/context/app';
|
||||
import { hp_getConfig } from '~server/context/global';
|
||||
import ManageBanner from './components/manage-banner';
|
||||
import DeleteUser from './dialogs/delete-user';
|
||||
import RenameUser from './dialogs/rename-user';
|
||||
import UserRow from './components/user-row';
|
||||
import { userAction } from './user-actions';
|
||||
|
||||
export async function loader({ request }: LoaderFunctionArgs<AppContext>) {
|
||||
const session = await getSession(request.headers.get('Cookie'));
|
||||
interface UserMachine extends User {
|
||||
machines: Machine[];
|
||||
}
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(request, Capabilities.read_users);
|
||||
if (!check) {
|
||||
// Not authorized to view this page
|
||||
throw new Error(
|
||||
'You do not have permission to view this page. Please contact your administrator.',
|
||||
);
|
||||
}
|
||||
|
||||
const writablePermission = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_users,
|
||||
);
|
||||
|
||||
const [machines, apiUsers] = await Promise.all([
|
||||
pull<{ nodes: Machine[] }>('v1/node', session.get('hsApiKey')!),
|
||||
pull<{ users: User[] }>('v1/user', session.get('hsApiKey')!),
|
||||
context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.get('api_key')!,
|
||||
),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
|
||||
]);
|
||||
|
||||
const users = apiUsers.users.map((user) => ({
|
||||
@ -34,18 +44,42 @@ export async function loader({ request }: LoaderFunctionArgs<AppContext>) {
|
||||
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
|
||||
}));
|
||||
|
||||
const { oidc } = hp_getConfig();
|
||||
const { mode, config } = hs_getConfig();
|
||||
let magic: string | undefined;
|
||||
const roles = users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((user) => {
|
||||
if (user.provider !== 'oidc') {
|
||||
return 'no-oidc';
|
||||
}
|
||||
|
||||
if (mode !== 'no') {
|
||||
if (config.dns.magic_dns) {
|
||||
magic = config.dns.base_domain;
|
||||
if (user.provider === 'oidc' && user.providerId) {
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = user.providerId.split('/').pop();
|
||||
if (!subject) {
|
||||
return 'invalid-oidc';
|
||||
}
|
||||
|
||||
const role = context.sessions.roleForSubject(subject);
|
||||
return role ?? 'no-role';
|
||||
}
|
||||
|
||||
// No role means the user is not registered in Headplane, but they
|
||||
// are in Headscale. We also need to handle what happens if someone
|
||||
// logs into the UI and they don't have a Headscale setup.
|
||||
return 'no-role';
|
||||
});
|
||||
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
magic = context.hs.c.dns.base_domain;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
oidc,
|
||||
writable: writablePermission, // whether the user can write to the API
|
||||
oidc: context.config.oidc,
|
||||
roles,
|
||||
magic,
|
||||
users,
|
||||
};
|
||||
@ -74,163 +108,33 @@ export default function Page() {
|
||||
Manage the users in your network and their permissions. Tip: You can
|
||||
drag machines between users to change ownership.
|
||||
</p>
|
||||
<ManageBanner oidc={data.oidc} />
|
||||
<ClientOnly fallback={<Users users={users} />}>
|
||||
{() => (
|
||||
<InteractiveUsers
|
||||
users={users}
|
||||
setUsers={setUsers}
|
||||
magic={data.magic}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
<ManageBanner oidc={data.oidc} isDisabled={!data.writable} />
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="text-left px-0.5">
|
||||
<th className="uppercase text-xs font-bold pb-2">User</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Role</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Created At</th>
|
||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody
|
||||
className={cn(
|
||||
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
|
||||
'border-t border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{users
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
user={user}
|
||||
role={data.roles[users.indexOf(user)]}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type UserMachine = User & { machines: Machine[] };
|
||||
|
||||
interface UserProps {
|
||||
users: UserMachine[];
|
||||
setUsers?: (users: UserMachine[]) => void;
|
||||
magic?: string;
|
||||
}
|
||||
|
||||
function Users({ users, magic }: UserProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||
{users.map((user) => (
|
||||
<UserCard key={user.id} user={user} magic={magic} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InteractiveUsers({ users, setUsers, magic }: UserProps) {
|
||||
const submit = useSubmit();
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
onDragEnd={(event) => {
|
||||
const { over, active } = event;
|
||||
if (!over) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update the UI optimistically
|
||||
const newUsers = new Array<UserMachine>();
|
||||
const reference = active.data as DataRef<Machine>;
|
||||
if (!reference.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore if the user is unchanged
|
||||
if (reference.current.user.name === over.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const user of users) {
|
||||
newUsers.push({
|
||||
...user,
|
||||
machines:
|
||||
over.id === user.name
|
||||
? [...user.machines, reference.current]
|
||||
: user.machines.filter((m) => m.id !== active.id),
|
||||
});
|
||||
}
|
||||
|
||||
setUsers?.(newUsers);
|
||||
const data = new FormData();
|
||||
data.append('action_id', 'change_owner');
|
||||
data.append('user_id', over.id.toString());
|
||||
data.append('node_id', reference.current.id);
|
||||
|
||||
submit(data, {
|
||||
method: 'POST',
|
||||
});
|
||||
}}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 auto-rows-min">
|
||||
{users.map((user) => (
|
||||
<UserCard key={user.id} user={user} magic={magic} />
|
||||
))}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function MachineChip({ machine }: { readonly machine: Machine }) {
|
||||
const { attributes, listeners, setNodeRef, transform } = useDraggable({
|
||||
id: machine.id,
|
||||
data: machine,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={cn(
|
||||
'flex items-center w-full gap-2 py-1',
|
||||
'hover:bg-headplane-50 dark:hover:bg-headplane-950 rounded-xl',
|
||||
)}
|
||||
style={{
|
||||
transform: transform
|
||||
? `translate3d(${transform.x.toString()}px, ${transform.y.toString()}px, 0)`
|
||||
: undefined,
|
||||
}}
|
||||
{...listeners}
|
||||
{...attributes}
|
||||
>
|
||||
<StatusCircle isOnline={machine.online} className="px-1 h-4 w-fit" />
|
||||
<Attribute
|
||||
name={machine.givenName}
|
||||
link={`machines/${machine.id}`}
|
||||
value={machine.ipAddresses[0]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CardProps {
|
||||
user: UserMachine;
|
||||
magic?: string;
|
||||
}
|
||||
|
||||
function UserCard({ user, magic }: CardProps) {
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
id: user.name,
|
||||
});
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef}>
|
||||
<Card
|
||||
variant="flat"
|
||||
className={cn(
|
||||
'max-w-full w-full overflow-visible h-full',
|
||||
isOver ? 'bg-headplane-100 dark:bg-headplane-800' : '',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<PersonIcon className="w-6 h-6" />
|
||||
<span className="text-lg font-mono">{user.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RenameUser user={user} />
|
||||
{user.machines.length === 0 ? (
|
||||
<DeleteUser user={user} />
|
||||
) : undefined}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{user.machines.map((machine) => (
|
||||
<MachineChip key={machine.id} machine={machine} />
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ErrorPopup type="embedded" />;
|
||||
}
|
||||
|
||||
@ -1,81 +1,141 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { del, post } from '~/utils/headscale';
|
||||
import { auth } from '~/utils/sessions.server';
|
||||
import { ActionFunctionArgs, Session, data } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities, Roles } from '~/server/web/roles';
|
||||
import { AuthSession } from '~/server/web/sessions';
|
||||
import { User } from '~/types';
|
||||
import { data400, data403 } from '~/utils/res';
|
||||
|
||||
export async function userAction({ request }: ActionFunctionArgs) {
|
||||
const session = await auth(request);
|
||||
if (!session) {
|
||||
return data({ success: false }, 401);
|
||||
export async function userAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(request, Capabilities.write_users);
|
||||
if (!check) {
|
||||
throw data403('You do not have permission to update users');
|
||||
}
|
||||
|
||||
const apiKey = session.get('api_key')!;
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const apiKey = session.get('hsApiKey');
|
||||
if (!apiKey) {
|
||||
return data({ success: false }, 401);
|
||||
throw data400('Missing `action_id` in the form data.');
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'create_user':
|
||||
return createUser(formData, apiKey);
|
||||
return createUser(formData, apiKey, context);
|
||||
case 'delete_user':
|
||||
return deleteUser(formData, apiKey);
|
||||
return deleteUser(formData, apiKey, context);
|
||||
case 'rename_user':
|
||||
return renameUser(formData, apiKey);
|
||||
case 'change_owner':
|
||||
return changeOwner(formData, apiKey);
|
||||
return renameUser(formData, apiKey, context);
|
||||
case 'reassign_user':
|
||||
return reassignUser(formData, apiKey, context, session);
|
||||
default:
|
||||
return data({ success: false }, 400);
|
||||
throw data400('Invalid `action_id` provided.');
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(formData: FormData, apiKey: string) {
|
||||
async function createUser(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const name = formData.get('username')?.toString();
|
||||
const displayName = formData.get('display_name')?.toString();
|
||||
const email = formData.get('email')?.toString();
|
||||
|
||||
if (!name) {
|
||||
return data({ success: false }, 400);
|
||||
throw data400('Missing `username` in the form data.');
|
||||
}
|
||||
|
||||
await post('v1/user', apiKey, {
|
||||
await context.client.post('v1/user', apiKey, {
|
||||
name,
|
||||
displayName,
|
||||
email,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteUser(formData: FormData, apiKey: string) {
|
||||
async function deleteUser(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
if (!userId) {
|
||||
return data({ success: false }, 400);
|
||||
throw data400('Missing `user_id` in the form data.');
|
||||
}
|
||||
|
||||
await del(`v1/user/${userId}`, apiKey);
|
||||
await context.client.delete(`v1/user/${userId}`, apiKey);
|
||||
}
|
||||
|
||||
async function renameUser(formData: FormData, apiKey: string) {
|
||||
async function renameUser(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
const newName = formData.get('new_name')?.toString();
|
||||
if (!userId || !newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await post(`v1/user/${userId}/rename/${newName}`, apiKey);
|
||||
}
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
async function changeOwner(formData: FormData, apiKey: string) {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
const nodeId = formData.get('node_id')?.toString();
|
||||
if (!userId || !nodeId) {
|
||||
return data({ success: false }, 400);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data400(`No user found with id: ${userId}`);
|
||||
}
|
||||
|
||||
await post(`v1/node/${nodeId}/user`, apiKey, {
|
||||
user: userId,
|
||||
});
|
||||
if (user.provider === 'oidc') {
|
||||
// OIDC users cannot be renamed via this endpoint, return an error
|
||||
throw data403('Users managed by OIDC cannot be renamed');
|
||||
}
|
||||
|
||||
await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey);
|
||||
}
|
||||
|
||||
async function reassignUser(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
const newRole = formData.get('new_role')?.toString();
|
||||
if (!userId || !newRole) {
|
||||
throw data400('Missing `user_id` or `new_role` in the form data.');
|
||||
}
|
||||
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user?.providerId) {
|
||||
throw data400('Specified user is not an OIDC user');
|
||||
}
|
||||
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = user.providerId?.split('/').pop();
|
||||
if (!subject) {
|
||||
throw data400(
|
||||
'Malformed `providerId` for the specified user. Cannot find subject.',
|
||||
);
|
||||
}
|
||||
|
||||
const result = await context.sessions.reassignSubject(
|
||||
subject,
|
||||
newRole as keyof typeof Roles,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
return data({ success: true });
|
||||
}
|
||||
|
||||
@ -1,14 +1,8 @@
|
||||
import { healthcheck } from '~/utils/headscale';
|
||||
import log from '~server/utils/log';
|
||||
|
||||
export async function loader() {
|
||||
let healthy = false;
|
||||
try {
|
||||
healthy = await healthcheck();
|
||||
} catch (error) {
|
||||
log.debug('APIC', 'Healthcheck failed %o', error);
|
||||
}
|
||||
import { LoaderFunctionArgs } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
|
||||
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
|
||||
const healthy = await context.client.healthcheck();
|
||||
return new Response(JSON.stringify({ status: healthy ? 'OK' : 'ERROR' }), {
|
||||
status: healthy ? 200 : 500,
|
||||
headers: {
|
||||
|
||||
29
app/server/README.md
Normal file
29
app/server/README.md
Normal file
@ -0,0 +1,29 @@
|
||||
# Headplane Server
|
||||
This code is responsible for all code that is necessary *before* any
|
||||
web server is started. It is the only part of the code that contains
|
||||
many side-effects (in this case, importing a module may run code).
|
||||
|
||||
# Hierarchy
|
||||
```
|
||||
server
|
||||
├── index.ts: Loads everything and starts the web server.
|
||||
├── config/
|
||||
│ ├── integration/
|
||||
│ │ ├── abstract.ts: Defines the abstract class for integrations.
|
||||
│ │ ├── docker.ts: Contains the Docker integration.
|
||||
│ │ ├── index.ts: Determines the correct integration to use (if any).
|
||||
│ │ ├── kubernetes.ts: Contains the Kubernetes integration.
|
||||
│ │ ├── proc.ts: Contains the Proc integration.
|
||||
│ ├── env.ts: Checks the environment variables for custom overrides.
|
||||
│ ├── loader.ts: Checks the configuration file and coalesces with ENV.
|
||||
│ ├── schema.ts: Defines the schema for the Headplane configuration.
|
||||
├── headscale/
|
||||
│ ├── api-client.ts: Creates the HTTP client that talks to the Headscale API.
|
||||
│ ├── api-error.ts: Contains the ResponseError definition.
|
||||
│ ├── config-loader.ts: Loads the Headscale configuration (if available).
|
||||
│ ├── config-schema.ts: Defines the schema for the Headscale configuration.
|
||||
├── web/
|
||||
│ ├── agent.ts: Handles setting up the agent WebSocket if needed.
|
||||
│ ├── oidc.ts: Loads and validates an OIDC configuration (if available).
|
||||
│ ├── roles.ts: Contains information about authentication permissions.
|
||||
│ ├── sessions.ts: Initializes the session store and methods to manage it.
|
||||
76
app/server/config/env.ts
Normal file
76
app/server/config/env.ts
Normal file
@ -0,0 +1,76 @@
|
||||
import { exit } from 'node:process';
|
||||
import { type } from 'arktype';
|
||||
import log from '~/utils/log';
|
||||
|
||||
// Custom type for boolean environment variables, allowing for values like
|
||||
// 1, true, yes, and on to count as a truthy value.
|
||||
const booleanEnv = type('string | undefined').pipe((v) => {
|
||||
return ['1', 'true', 'yes', 'on'].includes(v?.toLowerCase() ?? '');
|
||||
});
|
||||
|
||||
export const envVariables = {
|
||||
debugLog: 'HEADPLANE_DEBUG_LOG',
|
||||
envOverrides: 'HEADPLANE_LOAD_ENV_OVERRIDES',
|
||||
configPath: 'HEADPLANE_CONFIG_PATH',
|
||||
} as const;
|
||||
|
||||
export function configureLogger(env: string | undefined) {
|
||||
const result = booleanEnv(env);
|
||||
if (result instanceof type.errors) {
|
||||
log.error(
|
||||
'config',
|
||||
'HEADPLANE_DEBUG_LOG value is invalid: %s',
|
||||
result.summary,
|
||||
);
|
||||
log.info('config', 'Using a default value: false');
|
||||
log.debug = () => {}; // Disable debug logging if the value is invalid
|
||||
log.debugEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === false) {
|
||||
log.debug = () => {}; // Disable debug logging if the value is false
|
||||
log.debugEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('config', 'Debug logging has been enabled');
|
||||
log.debug('config', 'It is recommended this be disabled in production');
|
||||
}
|
||||
|
||||
export interface EnvOverrides {
|
||||
loadEnv: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function configureConfig(overrides: {
|
||||
loadEnv: string | undefined;
|
||||
path: string | undefined;
|
||||
}): EnvOverrides {
|
||||
const loadResult = booleanEnv(overrides.loadEnv);
|
||||
if (loadResult instanceof type.errors) {
|
||||
log.error(
|
||||
'config',
|
||||
'HEADPLANE_LOAD_ENV_OVERRIDES value is invalid: %s',
|
||||
loadResult.summary,
|
||||
);
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const pathResult = type('string | undefined')(overrides.path);
|
||||
if (pathResult instanceof type.errors) {
|
||||
log.error(
|
||||
'config',
|
||||
'HEADPLANE_CONFIG_PATH value is invalid: %s',
|
||||
pathResult.summary,
|
||||
);
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
loadEnv: loadResult,
|
||||
path: pathResult ?? '/etc/headplane/config.yaml',
|
||||
};
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
import type { ApiClient } from '~/server/headscale/api-client';
|
||||
|
||||
export abstract class Integration<T> {
|
||||
protected context: NonNullable<T>;
|
||||
constructor(context: T) {
|
||||
@ -9,6 +11,6 @@ export abstract class Integration<T> {
|
||||
}
|
||||
|
||||
abstract isAvailable(): Promise<boolean> | boolean;
|
||||
abstract onConfigChange(): Promise<void> | void;
|
||||
abstract onConfigChange(client: ApiClient): Promise<void> | void;
|
||||
abstract get name(): string;
|
||||
}
|
||||
229
app/server/config/integration/docker.ts
Normal file
229
app/server/config/integration/docker.ts
Normal file
@ -0,0 +1,229 @@
|
||||
import { constants, access } from 'node:fs/promises';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { Client } from 'undici';
|
||||
import { ApiClient } from '~/server/headscale/api-client';
|
||||
import log from '~/utils/log';
|
||||
import type { HeadplaneConfig } from '../schema';
|
||||
import { Integration } from './abstract';
|
||||
|
||||
interface DockerContainer {
|
||||
Id: string;
|
||||
Names: string[];
|
||||
}
|
||||
|
||||
type T = NonNullable<HeadplaneConfig['integration']>['docker'];
|
||||
export default class DockerIntegration extends Integration<T> {
|
||||
private maxAttempts = 10;
|
||||
private client: Client | undefined;
|
||||
|
||||
get name() {
|
||||
return 'Docker';
|
||||
}
|
||||
|
||||
async getContainerName(label: string, value: string): Promise<string> {
|
||||
if (!this.client) {
|
||||
throw new Error('Docker client is not initialized');
|
||||
}
|
||||
|
||||
const filters = encodeURIComponent(
|
||||
JSON.stringify({
|
||||
label: [`${label}=${value}`],
|
||||
}),
|
||||
);
|
||||
const { body } = await this.client.request({
|
||||
method: 'GET',
|
||||
path: `/containers/json?filters=${filters}`,
|
||||
});
|
||||
const containers: DockerContainer[] =
|
||||
(await body.json()) as DockerContainer[];
|
||||
if (containers.length > 1) {
|
||||
throw new Error(
|
||||
`Found multiple Docker containers matching label ${label}=${value}. Please specify a container name.`,
|
||||
);
|
||||
}
|
||||
if (containers.length === 0) {
|
||||
throw new Error(
|
||||
`No Docker containers found matching label: ${label}=${value}`,
|
||||
);
|
||||
}
|
||||
log.info(
|
||||
'config',
|
||||
'Found Docker container matching label: %s=%s',
|
||||
label,
|
||||
value,
|
||||
);
|
||||
return containers[0].Id;
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
// Perform a basic check to see if any of the required properties are set
|
||||
if (
|
||||
this.context.container_name.length === 0 &&
|
||||
!this.context.container_label
|
||||
) {
|
||||
log.error('config', 'Docker container name and label are both empty');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
this.context.container_name.length > 0 &&
|
||||
!this.context.container_label
|
||||
) {
|
||||
log.error(
|
||||
'config',
|
||||
'Docker container name and label are mutually exclusive',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Verify that Docker socket is reachable
|
||||
let url: URL | undefined;
|
||||
try {
|
||||
url = new URL(this.context.socket);
|
||||
} catch {
|
||||
log.error(
|
||||
'config',
|
||||
'Invalid Docker socket path: %s',
|
||||
this.context.socket,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
|
||||
log.error('config', 'Invalid Docker socket protocol: %s', url.protocol);
|
||||
return false;
|
||||
}
|
||||
|
||||
// The API is available as an HTTP endpoint and this
|
||||
// will simplify the fetching logic in undici
|
||||
if (url.protocol === 'tcp:') {
|
||||
// Apparently setting url.protocol doesn't work anymore?
|
||||
const fetchU = url.href.replace(url.protocol, 'http:');
|
||||
|
||||
try {
|
||||
log.info('config', 'Checking API: %s', fetchU);
|
||||
await fetch(new URL('/v1.30/version', fetchU).href);
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to connect to Docker API: %s', error);
|
||||
log.debug('config', 'Connection error: %o', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.client = new Client(fetchU);
|
||||
}
|
||||
|
||||
// Check if the socket is accessible
|
||||
if (url.protocol === 'unix:') {
|
||||
try {
|
||||
log.info('config', 'Checking socket: %s', url.pathname);
|
||||
await access(url.pathname, constants.R_OK);
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to access Docker socket: %s', url.pathname);
|
||||
log.debug('config', 'Access error: %o', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.client = new Client('http://localhost', {
|
||||
socketPath: url.pathname,
|
||||
});
|
||||
}
|
||||
if (this.client === undefined) {
|
||||
log.error('config', 'Failed to create Docker client');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.context.container_name.length === 0) {
|
||||
try {
|
||||
if (this.context.container_label === undefined) {
|
||||
log.error('config', 'Docker container label is not defined');
|
||||
return false;
|
||||
}
|
||||
const containerName = await this.getContainerName(
|
||||
this.context.container_label.name,
|
||||
this.context.container_label.value,
|
||||
);
|
||||
if (containerName.length === 0) {
|
||||
log.error(
|
||||
'config',
|
||||
'No Docker containers found matching label: %s=%s',
|
||||
this.context.container_label.name,
|
||||
this.context.container_label.value,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
this.context.container_name = containerName;
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to get Docker container name: %s', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
log.info('config', 'Using container: %s', this.context.container_name);
|
||||
|
||||
return this.client !== undefined;
|
||||
}
|
||||
|
||||
async onConfigChange(client: ApiClient) {
|
||||
if (!this.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('config', 'Restarting Headscale via Docker');
|
||||
|
||||
let attempts = 0;
|
||||
while (attempts <= this.maxAttempts) {
|
||||
log.debug(
|
||||
'config',
|
||||
'Restarting container: %s (attempt %d)',
|
||||
this.context.container_name,
|
||||
attempts,
|
||||
);
|
||||
|
||||
const response = await this.client.request({
|
||||
method: 'POST',
|
||||
path: `/v1.30/containers/${this.context.container_name}/restart`,
|
||||
});
|
||||
|
||||
if (response.statusCode !== 204) {
|
||||
if (attempts < this.maxAttempts) {
|
||||
attempts++;
|
||||
await setTimeout(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stringCode = response.statusCode.toString();
|
||||
const body = await response.body.text();
|
||||
throw new Error(`API request failed: ${stringCode} ${body}`);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
attempts = 0;
|
||||
while (attempts <= this.maxAttempts) {
|
||||
try {
|
||||
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
|
||||
const status = await client.healthcheck();
|
||||
if (status === false) {
|
||||
throw new Error('Headscale is not running');
|
||||
}
|
||||
|
||||
log.info('config', 'Headscale is up and running');
|
||||
return;
|
||||
} catch (error) {
|
||||
if (attempts < this.maxAttempts) {
|
||||
attempts++;
|
||||
await setTimeout(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
log.error(
|
||||
'config',
|
||||
'Missed restart deadline for %s',
|
||||
this.context.container_name,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
62
app/server/config/integration/index.ts
Normal file
62
app/server/config/integration/index.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import { HeadplaneConfig } from '~/server/config/schema';
|
||||
import log from '~/utils/log';
|
||||
import dockerIntegration from './docker';
|
||||
import kubernetesIntegration from './kubernetes';
|
||||
import procIntegration from './proc';
|
||||
|
||||
export async function loadIntegration(context: HeadplaneConfig['integration']) {
|
||||
const integration = getIntegration(context);
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await integration.isAvailable();
|
||||
if (!res) {
|
||||
log.error('config', 'Integration %s is not available', integration);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'config',
|
||||
'Failed to load integration %s: %s',
|
||||
integration,
|
||||
error,
|
||||
);
|
||||
log.debug('config', 'Loading error: %o', error);
|
||||
return;
|
||||
}
|
||||
|
||||
return integration;
|
||||
}
|
||||
|
||||
function getIntegration(integration: HeadplaneConfig['integration']) {
|
||||
const docker = integration?.docker;
|
||||
const k8s = integration?.kubernetes;
|
||||
const proc = integration?.proc;
|
||||
|
||||
if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) {
|
||||
log.debug('config', 'No integrations enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (docker?.enabled && k8s?.enabled && proc?.enabled) {
|
||||
log.error('config', 'Multiple integrations enabled, please pick one only');
|
||||
return;
|
||||
}
|
||||
|
||||
if (docker?.enabled) {
|
||||
log.info('config', 'Using Docker integration');
|
||||
return new dockerIntegration(integration?.docker);
|
||||
}
|
||||
|
||||
if (k8s?.enabled) {
|
||||
log.info('config', 'Using Kubernetes integration');
|
||||
return new kubernetesIntegration(integration?.kubernetes);
|
||||
}
|
||||
|
||||
if (proc?.enabled) {
|
||||
log.info('config', 'Using Proc integration');
|
||||
return new procIntegration(integration?.proc);
|
||||
}
|
||||
}
|
||||
@ -4,9 +4,9 @@ import { join, resolve } from 'node:path';
|
||||
import { kill } from 'node:process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
||||
import { HeadscaleError, healthcheck } from '~/utils/headscale';
|
||||
import { HeadplaneConfig } from '~server/context/parser';
|
||||
import log from '~server/utils/log';
|
||||
import { ApiClient } from '~/server/headscale/api-client';
|
||||
import log from '~/utils/log';
|
||||
import { HeadplaneConfig } from '../schema';
|
||||
import { Integration } from './abstract';
|
||||
|
||||
// TODO: Upgrade to the new CoreV1Api from @kubernetes/client-node
|
||||
@ -21,16 +21,16 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
|
||||
async isAvailable() {
|
||||
if (platform() !== 'linux') {
|
||||
log.error('INTG', 'Kubernetes is only available on Linux');
|
||||
log.error('config', 'Kubernetes is only available on Linux');
|
||||
return false;
|
||||
}
|
||||
|
||||
const svcRoot = Config.SERVICEACCOUNT_ROOT;
|
||||
try {
|
||||
log.debug('INTG', 'Checking Kubernetes service account at %s', svcRoot);
|
||||
log.debug('config', 'Checking Kubernetes service account at %s', svcRoot);
|
||||
const files = await readdir(svcRoot);
|
||||
if (files.length === 0) {
|
||||
log.error('INTG', 'Kubernetes service account not found');
|
||||
log.error('config', 'Kubernetes service account not found');
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -41,17 +41,17 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||
];
|
||||
|
||||
log.debug('INTG', 'Looking for %s', expectedFiles.join(', '));
|
||||
log.debug('config', 'Looking for %s', expectedFiles.join(', '));
|
||||
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
|
||||
log.error('INTG', 'Malformed Kubernetes service account');
|
||||
log.error('config', 'Malformed Kubernetes service account');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to access %s: %s', svcRoot, error);
|
||||
log.error('config', 'Failed to access %s: %s', svcRoot, error);
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Reading Kubernetes service account at %s', svcRoot);
|
||||
log.debug('config', 'Reading Kubernetes service account at %s', svcRoot);
|
||||
const namespace = await readFile(
|
||||
Config.SERVICEACCOUNT_NAMESPACE_PATH,
|
||||
'utf8',
|
||||
@ -59,39 +59,39 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
|
||||
// Some very ugly nesting but it's necessary
|
||||
if (this.context.validate_manifest === false) {
|
||||
log.warn('INTG', 'Skipping strict Pod status check');
|
||||
log.warn('config', 'Skipping strict Pod status check');
|
||||
} else {
|
||||
const pod = this.context.pod_name;
|
||||
if (!pod) {
|
||||
log.error('INTG', 'Missing POD_NAME variable');
|
||||
log.error('config', 'Missing POD_NAME variable');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pod.trim().length === 0) {
|
||||
log.error('INTG', 'Pod name is empty');
|
||||
log.error('config', 'Pod name is empty');
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
'INTG',
|
||||
'config',
|
||||
'Checking Kubernetes pod %s in namespace %s',
|
||||
pod,
|
||||
namespace,
|
||||
);
|
||||
|
||||
try {
|
||||
log.debug('INTG', 'Attempgin to get cluster KubeConfig');
|
||||
log.debug('config', 'Attempgin to get cluster KubeConfig');
|
||||
const kc = new KubeConfig();
|
||||
kc.loadFromCluster();
|
||||
|
||||
const cluster = kc.getCurrentCluster();
|
||||
if (!cluster) {
|
||||
log.error('INTG', 'Malformed kubeconfig');
|
||||
log.error('config', 'Malformed kubeconfig');
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info(
|
||||
'INTG',
|
||||
'config',
|
||||
'Service account connected to %s (%s)',
|
||||
cluster.name,
|
||||
cluster.server,
|
||||
@ -100,14 +100,14 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
const kCoreV1Api = kc.makeApiClient(CoreV1Api);
|
||||
|
||||
log.info(
|
||||
'INTG',
|
||||
'config',
|
||||
'Checking pod %s in namespace %s (%s)',
|
||||
pod,
|
||||
namespace,
|
||||
kCoreV1Api.basePath,
|
||||
);
|
||||
|
||||
log.debug('INTG', 'Reading pod info for %s', pod);
|
||||
log.debug('config', 'Reading pod info for %s', pod);
|
||||
const { response, body } = await kCoreV1Api.readNamespacedPod(
|
||||
pod,
|
||||
namespace,
|
||||
@ -115,36 +115,39 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
log.error(
|
||||
'INTG',
|
||||
'config',
|
||||
'Failed to read pod info: http %d',
|
||||
response.statusCode,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Got pod info: %o', body.spec);
|
||||
log.debug('config', 'Got pod info: %o', body.spec);
|
||||
const shared = body.spec?.shareProcessNamespace;
|
||||
if (shared === undefined) {
|
||||
log.error('INTG', 'Pod does not have spec.shareProcessNamespace set');
|
||||
log.error(
|
||||
'config',
|
||||
'Pod does not have spec.shareProcessNamespace set',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!shared) {
|
||||
log.error(
|
||||
'INTG',
|
||||
'config',
|
||||
'Pod has set but disabled spec.shareProcessNamespace',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info('INTG', 'Pod %s enabled shared processes', pod);
|
||||
log.info('config', 'Pod %s enabled shared processes', pod);
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to read pod info: %s', error);
|
||||
log.error('config', 'Failed to read pod info: %s', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Looking for namespaced process in /proc');
|
||||
log.debug('config', 'Looking for namespaced process in /proc');
|
||||
const dir = resolve('/proc');
|
||||
try {
|
||||
const subdirs = await readdir(dir);
|
||||
@ -157,13 +160,13 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
|
||||
const path = join('/proc', dir, 'cmdline');
|
||||
try {
|
||||
log.debug('INTG', 'Reading %s', path);
|
||||
log.debug('config', 'Reading %s', path);
|
||||
const data = await readFile(path, 'utf8');
|
||||
if (data.includes('headscale')) {
|
||||
return pid;
|
||||
}
|
||||
} catch (error) {
|
||||
log.debug('INTG', 'Failed to read %s: %s', path, error);
|
||||
log.debug('config', 'Failed to read %s: %s', path, error);
|
||||
}
|
||||
});
|
||||
|
||||
@ -176,10 +179,10 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Found Headscale processes: %o', pids);
|
||||
log.debug('config', 'Found Headscale processes: %o', pids);
|
||||
if (pids.length > 1) {
|
||||
log.error(
|
||||
'INTG',
|
||||
'config',
|
||||
'Found %d Headscale processes: %s',
|
||||
pids.length,
|
||||
pids.join(', '),
|
||||
@ -188,49 +191,45 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
}
|
||||
|
||||
if (pids.length === 0) {
|
||||
log.error('INTG', 'Could not find Headscale process');
|
||||
log.error('config', 'Could not find Headscale process');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.pid = pids[0];
|
||||
log.info('INTG', 'Found Headscale process with PID: %d', this.pid);
|
||||
log.info('config', 'Found Headscale process with PID: %d', this.pid);
|
||||
return true;
|
||||
} catch {
|
||||
log.error('INTG', 'Failed to read /proc');
|
||||
log.error('config', 'Failed to read /proc');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async onConfigChange() {
|
||||
async onConfigChange(client: ApiClient) {
|
||||
if (!this.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('INTG', 'Sending SIGTERM to Headscale');
|
||||
log.info('config', 'Sending SIGTERM to Headscale');
|
||||
kill(this.pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to send SIGTERM to Headscale: %s', error);
|
||||
log.debug('INTG', 'kill(1) error: %o', error);
|
||||
log.error('config', 'Failed to send SIGTERM to Headscale: %s', error);
|
||||
log.debug('config', 'kill(1) error: %o', error);
|
||||
}
|
||||
|
||||
await setTimeout(1000);
|
||||
let attempts = 0;
|
||||
while (attempts <= this.maxAttempts) {
|
||||
try {
|
||||
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
|
||||
await healthcheck();
|
||||
log.info('INTG', 'Headscale is up and running');
|
||||
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
|
||||
const status = await client.healthcheck();
|
||||
if (status === false) {
|
||||
throw new Error('Headscale is not running');
|
||||
}
|
||||
|
||||
log.info('config', 'Headscale is up and running');
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof HeadscaleError && error.status === 401) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (error instanceof HeadscaleError && error.status === 404) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempts < this.maxAttempts) {
|
||||
attempts++;
|
||||
await setTimeout(1000);
|
||||
@ -238,7 +237,7 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
}
|
||||
|
||||
log.error(
|
||||
'INTG',
|
||||
'config',
|
||||
'Missed restart deadline for Headscale (pid %d)',
|
||||
this.pid,
|
||||
);
|
||||
@ -3,9 +3,9 @@ import { platform } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { kill } from 'node:process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { HeadscaleError, healthcheck } from '~/utils/headscale';
|
||||
import { HeadplaneConfig } from '~server/context/parser';
|
||||
import log from '~server/utils/log';
|
||||
import { ApiClient } from '~/server/headscale/api-client';
|
||||
import log from '~/utils/log';
|
||||
import { HeadplaneConfig } from '../schema';
|
||||
import { Integration } from './abstract';
|
||||
|
||||
type T = NonNullable<HeadplaneConfig['integration']>['proc'];
|
||||
@ -19,11 +19,11 @@ export default class ProcIntegration extends Integration<T> {
|
||||
|
||||
async isAvailable() {
|
||||
if (platform() !== 'linux') {
|
||||
log.error('INTG', '/proc is only available on Linux');
|
||||
log.error('config', '/proc is only available on Linux');
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Checking /proc for Headscale process');
|
||||
log.debug('config', 'Checking /proc for Headscale process');
|
||||
const dir = resolve('/proc');
|
||||
try {
|
||||
const subdirs = await readdir(dir);
|
||||
@ -36,13 +36,13 @@ export default class ProcIntegration extends Integration<T> {
|
||||
|
||||
const path = join('/proc', dir, 'cmdline');
|
||||
try {
|
||||
log.debug('INTG', 'Reading %s', path);
|
||||
log.debug('config', 'Reading %s', path);
|
||||
const data = await readFile(path, 'utf8');
|
||||
if (data.includes('headscale')) {
|
||||
return pid;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to read %s: %s', path, error);
|
||||
log.error('config', 'Failed to read %s: %s', path, error);
|
||||
}
|
||||
});
|
||||
|
||||
@ -55,10 +55,10 @@ export default class ProcIntegration extends Integration<T> {
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('INTG', 'Found Headscale processes: %o', pids);
|
||||
log.debug('config', 'Found Headscale processes: %o', pids);
|
||||
if (pids.length > 1) {
|
||||
log.error(
|
||||
'INTG',
|
||||
'config',
|
||||
'Found %d Headscale processes: %s',
|
||||
pids.length,
|
||||
pids.join(', '),
|
||||
@ -67,49 +67,46 @@ export default class ProcIntegration extends Integration<T> {
|
||||
}
|
||||
|
||||
if (pids.length === 0) {
|
||||
log.error('INTG', 'Could not find Headscale process');
|
||||
log.error('config', 'Could not find Headscale process');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.pid = pids[0];
|
||||
log.info('INTG', 'Found Headscale process with PID: %d', this.pid);
|
||||
log.info('config', 'Found Headscale process with PID: %d', this.pid);
|
||||
return true;
|
||||
} catch {
|
||||
log.error('INTG', 'Failed to read /proc');
|
||||
log.error('config', 'Failed to read /proc');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async onConfigChange() {
|
||||
async onConfigChange(client: ApiClient) {
|
||||
if (!this.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log.info('INTG', 'Sending SIGTERM to Headscale');
|
||||
log.info('config', 'Sending SIGTERM to Headscale');
|
||||
kill(this.pid, 'SIGTERM');
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to send SIGTERM to Headscale: %s', error);
|
||||
log.debug('INTG', 'kill(1) error: %o', error);
|
||||
log.error('config', 'Failed to send SIGTERM to Headscale: %s', error);
|
||||
log.debug('config', 'kill(1) error: %o', error);
|
||||
}
|
||||
|
||||
await setTimeout(1000);
|
||||
let attempts = 0;
|
||||
while (attempts <= this.maxAttempts) {
|
||||
try {
|
||||
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
|
||||
await healthcheck();
|
||||
log.info('INTG', 'Headscale is up and running');
|
||||
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
|
||||
const status = await client.healthcheck();
|
||||
if (status === false) {
|
||||
log.error('config', 'Headscale is not running');
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('config', 'Headscale is up and running');
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof HeadscaleError && error.status === 401) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (error instanceof HeadscaleError && error.status === 404) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempts < this.maxAttempts) {
|
||||
attempts++;
|
||||
await setTimeout(1000);
|
||||
@ -117,7 +114,7 @@ export default class ProcIntegration extends Integration<T> {
|
||||
}
|
||||
|
||||
log.error(
|
||||
'INTG',
|
||||
'config',
|
||||
'Missed restart deadline for Headscale (pid %d)',
|
||||
this.pid,
|
||||
);
|
||||
225
app/server/config/loader.ts
Normal file
225
app/server/config/loader.ts
Normal file
@ -0,0 +1,225 @@
|
||||
import { constants, access, readFile } from 'node:fs/promises';
|
||||
import { env, exit } from 'node:process';
|
||||
import { type } from 'arktype';
|
||||
import { configDotenv } from 'dotenv';
|
||||
import { parseDocument } from 'yaml';
|
||||
import log from '~/utils/log';
|
||||
import { EnvOverrides, envVariables } from './env';
|
||||
import {
|
||||
HeadplaneConfig,
|
||||
headplaneConfig,
|
||||
partialHeadplaneConfig,
|
||||
} from './schema';
|
||||
|
||||
// loadConfig is a has a lifetime of the entire application and is
|
||||
// used to load the configuration for Headplane. It is called once.
|
||||
//
|
||||
// TODO: Potential for file watching on the configuration
|
||||
// But this may not be necessary as a use-case anyways
|
||||
export async function loadConfig({ loadEnv, path }: EnvOverrides) {
|
||||
log.debug('config', 'Loading configuration file: %s', path);
|
||||
const valid = await validateConfigPath(path);
|
||||
if (!valid) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const data = await loadConfigFile(path);
|
||||
if (!data) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
let config = validateConfig({ ...data, debug: log.debugEnabled });
|
||||
if (!config) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if (!loadEnv) {
|
||||
log.debug('config', 'Environment variable overrides are disabled');
|
||||
log.debug('config', 'This also disables the loading of a .env file');
|
||||
return config;
|
||||
}
|
||||
|
||||
log.info('config', 'Loading a .env file (if available)');
|
||||
configDotenv({ override: true });
|
||||
config = coalesceEnv(config);
|
||||
if (!config) {
|
||||
exit(1);
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
export async function hp_loadConfig() {
|
||||
// // OIDC Related Checks
|
||||
// if (config.oidc) {
|
||||
// if (!config.oidc.client_secret && !config.oidc.client_secret_path) {
|
||||
// log.error('CFGX', 'OIDC configuration is missing a secret, disabling');
|
||||
// log.error(
|
||||
// 'CFGX',
|
||||
// 'Please specify either `oidc.client_secret` or `oidc.client_secret_path`',
|
||||
// );
|
||||
// }
|
||||
// if (config.oidc?.strict_validation) {
|
||||
// const result = await testOidc(config.oidc);
|
||||
// if (!result) {
|
||||
// log.error('CFGX', 'OIDC configuration failed validation, disabling');
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
async function validateConfigPath(path: string) {
|
||||
try {
|
||||
await access(path, constants.F_OK | constants.R_OK);
|
||||
log.info('config', 'Found a valid configuration file at %s', path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error('config', 'Unable to read a configuration file at %s', path);
|
||||
log.error('config', '%s', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfigFile(path: string): Promise<unknown> {
|
||||
log.debug('config', 'Reading configuration file at %s', path);
|
||||
try {
|
||||
const data = await readFile(path, 'utf8');
|
||||
const configYaml = parseDocument(data);
|
||||
if (configYaml.errors.length > 0) {
|
||||
log.error('config', 'Cannot parse configuration file at %s', path);
|
||||
for (const error of configYaml.errors) {
|
||||
log.error('config', ` - ${error.toString()}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (configYaml.warnings.length > 0) {
|
||||
log.warn(
|
||||
'config',
|
||||
'Warnings while parsing configuration file at %s',
|
||||
path,
|
||||
);
|
||||
for (const warning of configYaml.warnings) {
|
||||
log.warn('config', ` - ${warning.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
return configYaml.toJSON() as unknown;
|
||||
} catch (e) {
|
||||
log.error('config', 'Error reading configuration file at %s', path);
|
||||
log.error('config', '%s', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateConfig(config: unknown) {
|
||||
log.debug('config', 'Validating Headplane configuration');
|
||||
const result = headplaneConfig(config);
|
||||
if (result instanceof type.errors) {
|
||||
log.error('config', 'Error validating Headplane configuration:');
|
||||
for (const [number, error] of result.entries()) {
|
||||
log.error('config', ` - (${number}): ${error.toString()}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function coalesceEnv(config: HeadplaneConfig) {
|
||||
const envConfig: Record<string, unknown> = {};
|
||||
const rootKeys: string[] = Object.values(envVariables);
|
||||
|
||||
// Typescript is still insanely stupid at nullish filtering
|
||||
const vars = Object.entries(env).filter(([key, value]) => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!key.startsWith('HEADPLANE_')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out the rootEnv configurations
|
||||
if (rootKeys.includes(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}) as [string, string][];
|
||||
|
||||
log.debug('config', 'Coalescing %s environment variables', vars.length);
|
||||
for (const [key, value] of vars) {
|
||||
const configPath = key.replace('HEADPLANE_', '').toLowerCase().split('__');
|
||||
log.debug(
|
||||
'config',
|
||||
` - ${key}=${new Array(value.length).fill('*').join('')}`,
|
||||
);
|
||||
|
||||
let current = envConfig;
|
||||
while (configPath.length > 1) {
|
||||
const path = configPath.shift() as string;
|
||||
if (!(path in current)) {
|
||||
current[path] = {};
|
||||
}
|
||||
|
||||
current = current[path] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
current[configPath[0]] = value;
|
||||
}
|
||||
|
||||
const toMerge = coalesceConfig(envConfig);
|
||||
if (!toMerge) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deep merge the environment variables into the configuration
|
||||
// This will overwrite any existing values in the configuration
|
||||
return deepMerge(config, toMerge);
|
||||
}
|
||||
|
||||
export function coalesceConfig(config: unknown) {
|
||||
log.debug('config', 'Revalidating config after coalescing variables');
|
||||
const out = partialHeadplaneConfig(config);
|
||||
if (out instanceof type.errors) {
|
||||
log.error('config', 'Error parsing variables:');
|
||||
for (const [number, error] of out.entries()) {
|
||||
log.error('config', ` - (${number}): ${error.toString()}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
type DeepPartial<T> =
|
||||
| {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
function deepMerge<T>(target: T, source: DeepPartial<T>): T {
|
||||
if (typeof target !== 'object' || typeof source !== 'object')
|
||||
return source as T;
|
||||
const result = { ...target } as T;
|
||||
|
||||
for (const key in source) {
|
||||
const val = source[key];
|
||||
if (val === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof val === 'object') {
|
||||
result[key] = deepMerge(result[key], val);
|
||||
continue;
|
||||
}
|
||||
|
||||
result[key] = val;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@ -1,7 +1,5 @@
|
||||
import { type } from 'arktype';
|
||||
import log from '~server/utils/log';
|
||||
|
||||
export type HeadplaneConfig = typeof headplaneConfig.infer;
|
||||
const stringToBool = type('string | boolean').pipe((v) => Boolean(v));
|
||||
const serverConfig = type({
|
||||
host: 'string.ip',
|
||||
@ -9,7 +7,7 @@ const serverConfig = type({
|
||||
cookie_secret: '32 <= string <= 32',
|
||||
cookie_secure: stringToBool,
|
||||
agent: type({
|
||||
authkey: 'string',
|
||||
authkey: 'string = ""',
|
||||
ttl: 'number.integer = 180000', // Default to 3 minutes
|
||||
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
|
||||
})
|
||||
@ -29,6 +27,7 @@ const oidcConfig = type({
|
||||
token_endpoint_auth_method:
|
||||
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"',
|
||||
redirect_uri: 'string.url?',
|
||||
user_storage_file: 'string = "/var/lib/headplane/users.json"',
|
||||
disable_api_key_login: stringToBool,
|
||||
headscale_api_key: 'string',
|
||||
strict_validation: stringToBool.default(true),
|
||||
@ -42,10 +41,16 @@ const headscaleConfig = type({
|
||||
config_strict: stringToBool,
|
||||
}).onDeepUndeclaredKey('reject');
|
||||
|
||||
const containerLabel = type({
|
||||
name: 'string',
|
||||
value: 'string',
|
||||
}).optional();
|
||||
|
||||
const dockerConfig = type({
|
||||
enabled: stringToBool,
|
||||
container_name: 'string',
|
||||
socket: 'string = "unix:///var/run/docker.sock"',
|
||||
container_label: containerLabel,
|
||||
});
|
||||
|
||||
const kubernetesConfig = type({
|
||||
@ -64,21 +69,21 @@ const integrationConfig = type({
|
||||
'proc?': procConfig,
|
||||
}).onDeepUndeclaredKey('reject');
|
||||
|
||||
const headplaneConfig = type({
|
||||
debug: stringToBool,
|
||||
server: serverConfig,
|
||||
'oidc?': oidcConfig,
|
||||
'integration?': integrationConfig,
|
||||
headscale: headscaleConfig,
|
||||
}).onDeepUndeclaredKey('reject');
|
||||
|
||||
const partialIntegrationConfig = type({
|
||||
'docker?': dockerConfig.partial(),
|
||||
'kubernetes?': kubernetesConfig.partial(),
|
||||
'proc?': procConfig.partial(),
|
||||
}).partial();
|
||||
|
||||
const partialHeadplaneConfig = type({
|
||||
export const headplaneConfig = type({
|
||||
debug: stringToBool,
|
||||
server: serverConfig,
|
||||
'oidc?': oidcConfig,
|
||||
'integration?': integrationConfig,
|
||||
headscale: headscaleConfig,
|
||||
}).onDeepUndeclaredKey('delete');
|
||||
|
||||
export const partialHeadplaneConfig = type({
|
||||
debug: stringToBool,
|
||||
server: serverConfig.partial(),
|
||||
'oidc?': oidcConfig.partial(),
|
||||
@ -86,34 +91,5 @@ const partialHeadplaneConfig = type({
|
||||
headscale: headscaleConfig.partial(),
|
||||
}).partial();
|
||||
|
||||
export function validateConfig(config: unknown) {
|
||||
log.debug('CFGX', 'Validating Headplane configuration...');
|
||||
const out = headplaneConfig(config);
|
||||
if (out instanceof type.errors) {
|
||||
log.error('CFGX', 'Error parsing Headplane configuration:');
|
||||
for (const [number, error] of out.entries()) {
|
||||
log.error('CFGX', ` (${number}): ${error.toString()}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('CFGX', 'Headplane configuration is valid.');
|
||||
return out;
|
||||
}
|
||||
|
||||
export function coalesceConfig(config: unknown) {
|
||||
log.debug('CFGX', 'Validating coalescing vars for configuration...');
|
||||
const out = partialHeadplaneConfig(config);
|
||||
if (out instanceof type.errors) {
|
||||
log.error('CFGX', 'Error parsing variables:');
|
||||
for (const [number, error] of out.entries()) {
|
||||
log.error('CFGX', ` (${number}): ${error.toString()}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('CFGX', 'Coalescing variables is valid.');
|
||||
return out;
|
||||
}
|
||||
export type HeadplaneConfig = typeof headplaneConfig.infer;
|
||||
export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer;
|
||||
126
app/server/headscale/api-client.ts
Normal file
126
app/server/headscale/api-client.ts
Normal file
@ -0,0 +1,126 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { Agent, Dispatcher, request } from 'undici';
|
||||
import log from '~/utils/log';
|
||||
import ResponseError from './api-error';
|
||||
|
||||
export async function createApiClient(base: string, certPath?: string) {
|
||||
if (!certPath) {
|
||||
return new ApiClient(new Agent(), base);
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug('config', 'Loading certificate from %s', certPath);
|
||||
const data = await readFile(certPath, 'utf8');
|
||||
|
||||
log.info('config', 'Using certificate from %s', certPath);
|
||||
return new ApiClient(new Agent({ connect: { ca: data.trim() } }), base);
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to load Headscale TLS cert: %s', error);
|
||||
log.debug('config', 'Error Details: %o', error);
|
||||
return new ApiClient(new Agent(), base);
|
||||
}
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private agent: Agent;
|
||||
private base: string;
|
||||
|
||||
constructor(agent: Agent, base: string) {
|
||||
this.agent = agent;
|
||||
this.base = base;
|
||||
}
|
||||
|
||||
private async defaultFetch(
|
||||
url: string,
|
||||
options?: Partial<Dispatcher.RequestOptions>,
|
||||
) {
|
||||
const method = options?.method ?? 'GET';
|
||||
log.debug('api', '%s %s', method, url);
|
||||
|
||||
return await request(new URL(url, this.base), {
|
||||
dispatcher: this.agent,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': `Headplane/${__VERSION__}`,
|
||||
},
|
||||
body: options?.body,
|
||||
method,
|
||||
});
|
||||
}
|
||||
|
||||
async healthcheck() {
|
||||
try {
|
||||
const res = await this.defaultFetch('/health');
|
||||
return res.statusCode === 200;
|
||||
} catch (error) {
|
||||
log.debug('api', 'Healthcheck failed %o', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async get<T = unknown>(url: string, key: string) {
|
||||
const res = await this.defaultFetch(`/api/${url}`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug('api', 'GET %s failed with status %d', url, res.statusCode);
|
||||
throw new ResponseError(res.statusCode, await res.body.text());
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async post<T = unknown>(url: string, key: string, body?: unknown) {
|
||||
const res = await this.defaultFetch(`/api/${url}`, {
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug('api', 'POST %s failed with status %d', url, res.statusCode);
|
||||
throw new ResponseError(res.statusCode, await res.body.text());
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async put<T = unknown>(url: string, key: string, body?: unknown) {
|
||||
const res = await this.defaultFetch(`/api/${url}`, {
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug('api', 'PUT %s failed with status %d', url, res.statusCode);
|
||||
throw new ResponseError(res.statusCode, await res.body.text());
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async delete<T = unknown>(url: string, key: string) {
|
||||
const res = await this.defaultFetch(`/api/${url}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug('api', 'DELETE %s failed with status %d', url, res.statusCode);
|
||||
throw new ResponseError(res.statusCode, await res.body.text());
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
}
|
||||
}
|
||||
19
app/server/headscale/api-error.ts
Normal file
19
app/server/headscale/api-error.ts
Normal file
@ -0,0 +1,19 @@
|
||||
// Represents an error that occurred during a response
|
||||
// Thrown when status codes are >= 400
|
||||
export default class ResponseError extends Error {
|
||||
status: number;
|
||||
response: string;
|
||||
responseObject?: Record<string, unknown>;
|
||||
|
||||
constructor(status: number, response: string) {
|
||||
super(`Response Error (${status}): ${response}`);
|
||||
this.name = 'ResponseError';
|
||||
this.status = status;
|
||||
this.response = response;
|
||||
|
||||
try {
|
||||
// Try to parse the response as JSON to get a response object
|
||||
this.responseObject = JSON.parse(response);
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
283
app/server/headscale/config-loader.ts
Normal file
283
app/server/headscale/config-loader.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import { constants, access, readFile, writeFile } from 'node:fs/promises';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { type } from 'arktype';
|
||||
import { Document, parseDocument } from 'yaml';
|
||||
import log from '~/utils/log';
|
||||
import { headscaleConfig } from './config-schema';
|
||||
|
||||
interface PatchConfig {
|
||||
path: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
// We need a class for the config because we need to be able to
|
||||
// support retrieving it via a getter but also be able to
|
||||
// patch it and to query it for its mode
|
||||
class HeadscaleConfig {
|
||||
private config?: typeof headscaleConfig.infer;
|
||||
private document?: Document;
|
||||
private access: 'rw' | 'ro' | 'no';
|
||||
private path?: string;
|
||||
private writeLock = false;
|
||||
|
||||
constructor(
|
||||
access: 'rw' | 'ro' | 'no',
|
||||
config?: typeof headscaleConfig.infer,
|
||||
document?: Document,
|
||||
path?: string,
|
||||
) {
|
||||
this.access = access;
|
||||
this.config = config;
|
||||
this.document = document;
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
readable() {
|
||||
return this.access !== 'no';
|
||||
}
|
||||
|
||||
writable() {
|
||||
return this.access === 'rw';
|
||||
}
|
||||
|
||||
get c() {
|
||||
return this.config;
|
||||
}
|
||||
|
||||
async patch(patches: PatchConfig[]) {
|
||||
if (!this.path || !this.document || !this.readable() || !this.writable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('config', 'Patching Headscale configuration');
|
||||
for (const patch of patches) {
|
||||
const { path, value } = patch;
|
||||
log.debug('config', 'Patching %s with %o', path, value);
|
||||
|
||||
// If the key is something like `test.bar."foo.bar"`, then we treat
|
||||
// the foo.bar as a single key, and not as two keys, so that needs
|
||||
// to be split correctly.
|
||||
|
||||
// Iterate through each character, and if we find a dot, we check if
|
||||
// the next character is a quote, and if it is, we skip until the next
|
||||
// quote, and then we skip the next character, which should be a dot.
|
||||
// If it's not a quote, we split it.
|
||||
const key = [];
|
||||
let current = '';
|
||||
let quote = false;
|
||||
|
||||
for (const char of path) {
|
||||
if (char === '"') {
|
||||
quote = !quote;
|
||||
}
|
||||
|
||||
if (char === '.' && !quote) {
|
||||
key.push(current);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
key.push(current.replaceAll('"', ''));
|
||||
if (value === null) {
|
||||
this.document.deleteIn(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
this.document.setIn(key, value);
|
||||
}
|
||||
|
||||
// Revalidate our configuration and update the config
|
||||
// object with the new configuration
|
||||
log.info('config', 'Revalidating Headscale configuration');
|
||||
const config = validateConfig(this.document.toJSON());
|
||||
if (!config) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug(
|
||||
'config',
|
||||
'Writing updated Headscale configuration to %s',
|
||||
this.path,
|
||||
);
|
||||
|
||||
// We need to lock the writeLock so that we don't try to write
|
||||
// to the file while we're already writing to it
|
||||
while (this.writeLock) {
|
||||
await setTimeout(100);
|
||||
}
|
||||
|
||||
this.writeLock = true;
|
||||
await writeFile(this.path, this.document.toString(), 'utf8');
|
||||
this.config = config;
|
||||
this.writeLock = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadHeadscaleConfig(path?: string, strict = true) {
|
||||
if (!path) {
|
||||
log.debug('config', 'No Headscale configuration file was provided');
|
||||
return new HeadscaleConfig('no');
|
||||
}
|
||||
|
||||
log.debug('config', 'Loading Headscale configuration file: %s', path);
|
||||
const { r, w } = await validateConfigPath(path);
|
||||
if (!r) {
|
||||
return new HeadscaleConfig('no');
|
||||
}
|
||||
|
||||
const document = await loadConfigFile(path);
|
||||
if (!document) {
|
||||
return new HeadscaleConfig('no');
|
||||
}
|
||||
|
||||
if (!strict) {
|
||||
return new HeadscaleConfig(
|
||||
w ? 'rw' : 'ro',
|
||||
augmentUnstrictConfig(document.toJSON()),
|
||||
document,
|
||||
path,
|
||||
);
|
||||
}
|
||||
|
||||
const config = validateConfig(document.toJSON());
|
||||
if (!config) {
|
||||
return new HeadscaleConfig('no');
|
||||
}
|
||||
|
||||
return new HeadscaleConfig(w ? 'rw' : 'ro', config, document, path);
|
||||
}
|
||||
|
||||
async function validateConfigPath(path: string) {
|
||||
try {
|
||||
await access(path, constants.F_OK | constants.R_OK);
|
||||
log.info(
|
||||
'config',
|
||||
'Found a valid Headscale configuration file at %s',
|
||||
path,
|
||||
);
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'config',
|
||||
'Unable to read a Headscale configuration file at %s',
|
||||
path,
|
||||
);
|
||||
log.error('config', '%s', error);
|
||||
return { w: false, r: false };
|
||||
}
|
||||
|
||||
try {
|
||||
await access(path, constants.F_OK | constants.W_OK);
|
||||
return { w: true, r: true };
|
||||
} catch (error) {
|
||||
log.warn(
|
||||
'config',
|
||||
'Headscale configuration file at %s is not writable',
|
||||
path,
|
||||
);
|
||||
return { w: false, r: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfigFile(path: string) {
|
||||
log.debug('config', 'Reading Headscale configuration file at %s', path);
|
||||
try {
|
||||
const data = await readFile(path, 'utf8');
|
||||
const configYaml = parseDocument(data);
|
||||
if (configYaml.errors.length > 0) {
|
||||
log.error(
|
||||
'config',
|
||||
'Cannot parse Headscale configuration file at %s',
|
||||
path,
|
||||
);
|
||||
for (const error of configYaml.errors) {
|
||||
log.error('config', ` - ${error.toString()}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return configYaml;
|
||||
} catch (e) {
|
||||
log.error(
|
||||
'config',
|
||||
'Error reading Headscale configuration file at %s',
|
||||
path,
|
||||
);
|
||||
log.error('config', '%s', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function validateConfig(config: unknown) {
|
||||
log.debug('config', 'Validating Headscale configuration');
|
||||
const result = headscaleConfig(config);
|
||||
if (result instanceof type.errors) {
|
||||
log.error('config', 'Error validating Headscale configuration:');
|
||||
for (const [number, error] of result.entries()) {
|
||||
log.error('config', ` - (${number}): ${error.toString()}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// If config_strict is false, we set the defaults and disable
|
||||
// the schema checking for the values that are not present
|
||||
function augmentUnstrictConfig(loaded: Partial<typeof headscaleConfig.infer>) {
|
||||
log.debug('config', 'Augmenting Headscale configuration in non-strict mode');
|
||||
const config = {
|
||||
...loaded,
|
||||
tls_letsencrypt_cache_dir:
|
||||
loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache',
|
||||
tls_letsencrypt_challenge_type:
|
||||
loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01',
|
||||
grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443',
|
||||
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
|
||||
randomize_client_port: loaded.randomize_client_port ?? false,
|
||||
unix_socket: loaded.unix_socket ?? '/var/run/headscale/headscale.sock',
|
||||
unix_socket_permission: loaded.unix_socket_permission ?? '0770',
|
||||
|
||||
log: loaded.log ?? {
|
||||
level: 'info',
|
||||
format: 'text',
|
||||
},
|
||||
|
||||
logtail: loaded.logtail ?? {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
prefixes: loaded.prefixes ?? {
|
||||
allocation: 'sequential',
|
||||
v4: '',
|
||||
v6: '',
|
||||
},
|
||||
|
||||
dns: loaded.dns ?? {
|
||||
nameservers: {
|
||||
global: [],
|
||||
split: {},
|
||||
},
|
||||
search_domains: [],
|
||||
extra_records: [],
|
||||
magic_dns: false,
|
||||
base_domain: 'headscale.net',
|
||||
},
|
||||
};
|
||||
|
||||
log.warn('config', 'Headscale configuration was loaded in non-strict mode');
|
||||
log.warn('config', 'This is very dangerous and comes with a few caveats:');
|
||||
log.warn('config', ' - Headplane could very easily crash');
|
||||
log.warn('config', ' - Headplane could break your Headscale installation');
|
||||
log.warn(
|
||||
'config',
|
||||
' - The UI could throw random errors/show incorrect data',
|
||||
);
|
||||
|
||||
return config as typeof headscaleConfig.infer;
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import { type } from 'arktype';
|
||||
import log from '~server/utils/log';
|
||||
|
||||
const goBool = type('boolean | "true" | "false"').pipe((v) => {
|
||||
if (v === 'true') return true;
|
||||
@ -46,7 +45,7 @@ const databaseConfig = type({
|
||||
// Not as strict parsing because we just need the values
|
||||
// to be slightly truthy enough to safely modify them
|
||||
export type HeadscaleConfig = typeof headscaleConfig.infer;
|
||||
const headscaleConfig = type({
|
||||
export const headscaleConfig = type({
|
||||
server_url: 'string',
|
||||
listen_addr: 'string',
|
||||
'metrics_listen_addr?': 'string',
|
||||
@ -56,8 +55,8 @@ const headscaleConfig = type({
|
||||
private_key_path: 'string',
|
||||
},
|
||||
prefixes: {
|
||||
v4: 'string',
|
||||
v6: 'string',
|
||||
v4: 'string?',
|
||||
v6: 'string?',
|
||||
allocation: '"sequential" | "random" = "sequential"',
|
||||
},
|
||||
derp: {
|
||||
@ -147,82 +146,3 @@ const headscaleConfig = type({
|
||||
|
||||
randomize_client_port: goBool.default(false),
|
||||
});
|
||||
|
||||
export function validateConfig(config: unknown, strict: boolean) {
|
||||
log.debug('CFGX', 'Validating Headscale configuration...');
|
||||
const out = strict
|
||||
? headscaleConfig(config)
|
||||
: headscaleConfig(augmentUnstrictConfig(config as HeadscaleConfig));
|
||||
|
||||
if (out instanceof type.errors) {
|
||||
log.error('CFGX', 'Error parsing Headscale configuration:');
|
||||
for (const [number, error] of out.entries()) {
|
||||
log.error('CFGX', ` (${number}): ${error.toString()}`);
|
||||
}
|
||||
|
||||
log.error('CFGX', '');
|
||||
log.error('CFGX', 'Resolve these issues and try again.');
|
||||
log.error('CFGX', 'Headplane will operate without the config');
|
||||
log.error('CFGX', '');
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('CFGX', 'Headscale configuration is valid.');
|
||||
return out;
|
||||
}
|
||||
|
||||
// If config_strict is false, we set the defaults and disable
|
||||
// the schema checking for the values that are not present
|
||||
function augmentUnstrictConfig(
|
||||
loaded: Partial<HeadscaleConfig>,
|
||||
): HeadscaleConfig {
|
||||
log.debug('CFGX', 'Loaded Headscale configuration in non-strict mode');
|
||||
const config = {
|
||||
...loaded,
|
||||
tls_letsencrypt_cache_dir:
|
||||
loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache',
|
||||
tls_letsencrypt_challenge_type:
|
||||
loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01',
|
||||
grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443',
|
||||
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
|
||||
randomize_client_port: loaded.randomize_client_port ?? false,
|
||||
unix_socket: loaded.unix_socket ?? '/var/run/headscale/headscale.sock',
|
||||
unix_socket_permission: loaded.unix_socket_permission ?? '0770',
|
||||
|
||||
log: loaded.log ?? {
|
||||
level: 'info',
|
||||
format: 'text',
|
||||
},
|
||||
|
||||
logtail: loaded.logtail ?? {
|
||||
enabled: false,
|
||||
},
|
||||
|
||||
prefixes: loaded.prefixes ?? {
|
||||
allocation: 'sequential',
|
||||
v4: '',
|
||||
v6: '',
|
||||
},
|
||||
|
||||
dns: loaded.dns ?? {
|
||||
nameservers: {
|
||||
global: [],
|
||||
split: {},
|
||||
},
|
||||
search_domains: [],
|
||||
extra_records: [],
|
||||
magic_dns: false,
|
||||
base_domain: 'headscale.net',
|
||||
},
|
||||
};
|
||||
|
||||
log.warn('CFGX', 'Loaded Headscale configuration in non-strict mode');
|
||||
log.warn('CFGX', 'By using this mode you forfeit GitHub issue support');
|
||||
log.warn('CFGX', 'This is very dangerous and comes with a few caveats:');
|
||||
log.warn('CFGX', ' Headplane could very easily crash');
|
||||
log.warn('CFGX', ' Headplane could break your Headscale installation');
|
||||
log.warn('CFGX', ' The UI could throw random errors/show incorrect data');
|
||||
log.warn('CFGX', '');
|
||||
|
||||
return config as HeadscaleConfig;
|
||||
}
|
||||
105
app/server/index.ts
Normal file
105
app/server/index.ts
Normal file
@ -0,0 +1,105 @@
|
||||
import { env, versions } from 'node:process';
|
||||
import type { UpgradeWebSocket } from 'hono/ws';
|
||||
import { createHonoServer } from 'react-router-hono-server/node';
|
||||
import type { WebSocket } from 'ws';
|
||||
import log from '~/utils/log';
|
||||
import { configureConfig, configureLogger, envVariables } from './config/env';
|
||||
import { loadIntegration } from './config/integration';
|
||||
import { loadConfig } from './config/loader';
|
||||
import { createApiClient } from './headscale/api-client';
|
||||
import { loadHeadscaleConfig } from './headscale/config-loader';
|
||||
import { loadAgentSocket } from './web/agent';
|
||||
import { createOidcClient } from './web/oidc';
|
||||
import { createSessionStorage } from './web/sessions';
|
||||
|
||||
declare global {
|
||||
const __PREFIX__: string;
|
||||
const __VERSION__: string;
|
||||
}
|
||||
|
||||
// MARK: Side-Effects
|
||||
// This module contains a side-effect because everything running here
|
||||
// exists for the lifetime of the process, making it appropriate.
|
||||
log.info('server', 'Running Node.js %s', versions.node);
|
||||
configureLogger(env[envVariables.debugLog]);
|
||||
const config = await loadConfig(
|
||||
configureConfig({
|
||||
loadEnv: env[envVariables.envOverrides],
|
||||
path: env[envVariables.configPath],
|
||||
}),
|
||||
);
|
||||
|
||||
// We also use this file to load anything needed by the react router code.
|
||||
// These are usually per-request things that we need access to, like the
|
||||
// helper that can issue and revoke cookies.
|
||||
export type LoadContext = typeof appLoadContext;
|
||||
const appLoadContext = {
|
||||
config,
|
||||
hs: await loadHeadscaleConfig(
|
||||
config.headscale.config_path,
|
||||
config.headscale.config_strict,
|
||||
),
|
||||
|
||||
// TODO: Better cookie options in config
|
||||
sessions: await createSessionStorage(
|
||||
{
|
||||
name: '_hp_session',
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
secure: config.server.cookie_secure,
|
||||
secrets: [config.server.cookie_secret],
|
||||
},
|
||||
config.oidc?.user_storage_file,
|
||||
),
|
||||
|
||||
client: await createApiClient(
|
||||
config.headscale.url,
|
||||
config.headscale.tls_cert_path,
|
||||
),
|
||||
|
||||
agents: await loadAgentSocket(
|
||||
config.server.agent.authkey,
|
||||
config.server.agent.cache_path,
|
||||
config.server.agent.ttl,
|
||||
),
|
||||
|
||||
integration: await loadIntegration(config.integration),
|
||||
oidc: config.oidc ? await createOidcClient(config.oidc) : undefined,
|
||||
};
|
||||
|
||||
declare module 'react-router' {
|
||||
interface AppLoadContext extends LoadContext {}
|
||||
}
|
||||
|
||||
export default createHonoServer({
|
||||
useWebSocket: true,
|
||||
overrideGlobalObjects: true,
|
||||
port: config.server.port,
|
||||
hostname: config.server.host,
|
||||
|
||||
// Only log in development mode
|
||||
defaultLogger: import.meta.env.DEV,
|
||||
getLoadContext() {
|
||||
// TODO: This is the place where we can handle reverse proxy translation
|
||||
// This is better than doing it in the OIDC client, since we can do it
|
||||
// for all requests, not just OIDC ones.
|
||||
return appLoadContext;
|
||||
},
|
||||
|
||||
configure(app, { upgradeWebSocket }) {
|
||||
const agentManager = appLoadContext.agents;
|
||||
if (agentManager) {
|
||||
app.get(
|
||||
`${__PREFIX__}/_dial`,
|
||||
// We need this since we cannot pass the WSEvents context
|
||||
// Also important to not pass the callback directly
|
||||
// since we need to retain `this` context
|
||||
(upgradeWebSocket as UpgradeWebSocket<WebSocket>)((c) =>
|
||||
agentManager.configureSocket(c),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
listeningListener(info) {
|
||||
log.info('server', 'Running on %s:%s', info.address, info.port);
|
||||
},
|
||||
});
|
||||
283
app/server/web/agent.ts
Normal file
283
app/server/web/agent.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { open, readFile, writeFile } from 'node:fs/promises';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { getConnInfo } from '@hono/node-server/conninfo';
|
||||
import { type } from 'arktype';
|
||||
import type { Context } from 'hono';
|
||||
import type { WSContext, WSEvents } from 'hono/ws';
|
||||
import { WebSocket } from 'ws';
|
||||
import { HostInfo } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
|
||||
export async function loadAgentSocket(
|
||||
authkey: string,
|
||||
path: string,
|
||||
ttl: number,
|
||||
) {
|
||||
if (authkey.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const handle = await open(path, 'w');
|
||||
log.info('agent', 'Using agent cache file at %s', path);
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
log.info('agent', 'Agent cache file not accessible at %s', path);
|
||||
log.debug('agent', 'Error details: %s', error);
|
||||
return;
|
||||
}
|
||||
|
||||
const cache = new TimedCache<HostInfo>(ttl, path);
|
||||
return new AgentManager(cache, authkey);
|
||||
}
|
||||
|
||||
class AgentManager {
|
||||
private cache: TimedCache<HostInfo>;
|
||||
private agents: Map<string, WSContext>;
|
||||
private timers: Map<string, NodeJS.Timeout>;
|
||||
private authkey: string;
|
||||
|
||||
constructor(cache: TimedCache<HostInfo>, authkey: string) {
|
||||
this.cache = cache;
|
||||
this.authkey = authkey;
|
||||
this.agents = new Map();
|
||||
this.timers = new Map();
|
||||
}
|
||||
|
||||
tailnetIDs() {
|
||||
return Array.from(this.agents.keys());
|
||||
}
|
||||
|
||||
lookup(nodeIds: string[]) {
|
||||
const entries = this.cache.toJSON();
|
||||
const missing = nodeIds.filter((nodeId) => !entries[nodeId]);
|
||||
if (missing.length > 0) {
|
||||
this.requestData(missing);
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
// Request data from all connected agents
|
||||
// This does not return anything, but caches the data which then needs to be
|
||||
// queried by the caller separately.
|
||||
private requestData(nodeList: string[]) {
|
||||
const NodeIDs = [...new Set(nodeList)];
|
||||
NodeIDs.map((node) => {
|
||||
log.debug('agent', 'Requesting agent data for %s', node);
|
||||
});
|
||||
|
||||
for (const agent of this.agents.values()) {
|
||||
agent.send(JSON.stringify({ NodeIDs }));
|
||||
}
|
||||
}
|
||||
|
||||
// Since we are using Node, Hono is built on 'ws' WebSocket types.
|
||||
configureSocket(c: Context): WSEvents<WebSocket> {
|
||||
return {
|
||||
onOpen: (_, ws) => {
|
||||
const id = c.req.header('x-headplane-tailnet-id');
|
||||
if (!id) {
|
||||
log.warn(
|
||||
'agent',
|
||||
'Rejecting an agent WebSocket connection without a tailnet ID',
|
||||
);
|
||||
ws.close(1008, 'ERR_INVALID_TAILNET_ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const auth = c.req.header('authorization');
|
||||
if (auth !== `Bearer ${this.authkey}`) {
|
||||
log.warn('agent', 'Rejecting an unauthorized WebSocket connection');
|
||||
|
||||
const info = getConnInfo(c);
|
||||
if (info.remote.address) {
|
||||
log.warn('agent', 'Agent source IP: %s', info.remote.address);
|
||||
}
|
||||
|
||||
ws.close(1008, 'ERR_UNAUTHORIZED');
|
||||
return;
|
||||
}
|
||||
|
||||
const pinger = setInterval(() => {
|
||||
if (ws.readyState !== 1) {
|
||||
clearInterval(pinger);
|
||||
return;
|
||||
}
|
||||
|
||||
ws.raw?.ping();
|
||||
}, 30000);
|
||||
|
||||
this.agents.set(id, ws);
|
||||
this.timers.set(id, pinger);
|
||||
},
|
||||
|
||||
onClose: () => {
|
||||
const id = c.req.header('x-headplane-tailnet-id');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(this.timers.get(id));
|
||||
this.agents.delete(id);
|
||||
},
|
||||
|
||||
onError: (event, ws) => {
|
||||
const id = c.req.header('x-headplane-tailnet-id');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearInterval(this.timers.get(id));
|
||||
if (event instanceof ErrorEvent) {
|
||||
log.error('agent', 'WebSocket error: %s', event.message);
|
||||
}
|
||||
|
||||
log.debug('agent', 'Closing agent WebSocket connection');
|
||||
ws.close(1011, 'ERR_INTERNAL_ERROR');
|
||||
},
|
||||
|
||||
// This is where we receive the data from the agent
|
||||
// Requests are made in the AgentManager.requestData function
|
||||
onMessage: (event, ws) => {
|
||||
const id = c.req.header('x-headplane-tailnet-id');
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = JSON.parse(event.data.toString());
|
||||
log.debug('agent', 'Received agent data from %s', id);
|
||||
for (const [node, info] of Object.entries<HostInfo>(data)) {
|
||||
this.cache.set(node, info);
|
||||
log.debug('agent', 'Cached HostInfo for %s', node);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const diskSchema = type({
|
||||
key: 'string',
|
||||
value: 'unknown',
|
||||
expires: 'number?',
|
||||
}).array();
|
||||
|
||||
// A persistent HashMap with a TTL for each key
|
||||
class TimedCache<V> {
|
||||
private _cache = new Map<string, V>();
|
||||
private _timings = new Map<string, number>();
|
||||
|
||||
// Default TTL is 1 minute
|
||||
private defaultTTL: number;
|
||||
private filePath: string;
|
||||
private writeLock = false;
|
||||
|
||||
// Last flush ID is essentially a hash of the flush contents
|
||||
// Prevents unnecessary flushing if nothing has changed
|
||||
private lastFlushId = '';
|
||||
|
||||
constructor(defaultTTL: number, filePath: string) {
|
||||
this.defaultTTL = defaultTTL;
|
||||
this.filePath = filePath;
|
||||
|
||||
// Load the cache from disk and then queue flushes every 10 seconds
|
||||
this.load().then(() => {
|
||||
setInterval(() => this.flush(), 10000);
|
||||
});
|
||||
}
|
||||
|
||||
set(key: string, value: V, ttl: number = this.defaultTTL) {
|
||||
this._cache.set(key, value);
|
||||
this._timings.set(key, Date.now() + ttl);
|
||||
}
|
||||
|
||||
get(key: string) {
|
||||
const value = this._cache.get(key);
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const expires = this._timings.get(key);
|
||||
if (!expires || expires < Date.now()) {
|
||||
this._cache.delete(key);
|
||||
this._timings.delete(key);
|
||||
return;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
// Map into a Record without any TTLs
|
||||
toJSON() {
|
||||
const result: Record<string, V> = {};
|
||||
for (const [key, value] of this._cache.entries()) {
|
||||
result[key] = value;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// WARNING: This function expects that this.filePath is NOT ENOENT
|
||||
private async load() {
|
||||
const data = await readFile(this.filePath, 'utf-8');
|
||||
const cache = () => {
|
||||
try {
|
||||
return JSON.parse(data);
|
||||
} catch (e) {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const diskData = cache();
|
||||
if (diskData === undefined) {
|
||||
log.error('agent', 'Failed to load cache at %s', this.filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const cacheData = diskSchema(diskData);
|
||||
if (cacheData instanceof type.errors) {
|
||||
log.debug('agent', 'Failed to load cache at %s', this.filePath);
|
||||
log.debug('agent', 'Error details: %s', cacheData.toString());
|
||||
|
||||
// Skip loading the cache (it should be overwritten soon)
|
||||
return;
|
||||
}
|
||||
|
||||
for (const { key, value, expires } of diskData) {
|
||||
this._cache.set(key, value);
|
||||
this._timings.set(key, expires);
|
||||
}
|
||||
|
||||
log.info('agent', 'Loaded cache from %s', this.filePath);
|
||||
}
|
||||
|
||||
private async flush() {
|
||||
const data = Array.from(this._cache.entries()).map(([key, value]) => {
|
||||
return { key, value, expires: this._timings.get(key) };
|
||||
});
|
||||
|
||||
if (data.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate the hash of the data
|
||||
const dumpData = JSON.stringify(data);
|
||||
const sha = createHash('sha256').update(dumpData).digest('hex');
|
||||
if (sha === this.lastFlushId) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We need to lock the writeLock so that we don't try to write
|
||||
// to the file while we're already writing to it
|
||||
while (this.writeLock) {
|
||||
await setTimeout(100);
|
||||
}
|
||||
|
||||
this.writeLock = true;
|
||||
await writeFile(this.filePath, dumpData, 'utf-8');
|
||||
log.debug('agent', 'Flushed cache to %s', this.filePath);
|
||||
this.lastFlushId = sha;
|
||||
this.writeLock = false;
|
||||
}
|
||||
}
|
||||
150
app/server/web/oidc.ts
Normal file
150
app/server/web/oidc.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import * as client from 'openid-client';
|
||||
import log from '~/utils/log';
|
||||
import type { HeadplaneConfig } from '../config/schema';
|
||||
|
||||
async function loadClientSecret(path: string) {
|
||||
// We need to interpolate environment variables into the path
|
||||
// Path formatting can be like ${ENV_NAME}/path/to/secret
|
||||
const matches = path.match(/\${(.*?)}/g);
|
||||
let resolvedPath = path;
|
||||
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
const env = match.slice(2, -1);
|
||||
const value = process.env[env];
|
||||
if (!value) {
|
||||
log.error('config', 'Environment variable %s is not set', env);
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('config', 'Interpolating %s with %s', match, value);
|
||||
resolvedPath = resolvedPath.replace(match, value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug('config', 'Reading client secret from %s', resolvedPath);
|
||||
const secret = await readFile(resolvedPath, 'utf-8');
|
||||
if (secret.trim().length === 0) {
|
||||
log.error('config', 'Empty OIDC client secret');
|
||||
return;
|
||||
}
|
||||
|
||||
return secret;
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to read client secret from %s', path);
|
||||
log.error('config', 'Error: %s', error);
|
||||
log.debug('config', 'Error details: %o', error);
|
||||
}
|
||||
}
|
||||
|
||||
function clientAuthMethod(
|
||||
method: string,
|
||||
): (secret: string) => client.ClientAuth {
|
||||
switch (method) {
|
||||
case 'client_secret_post':
|
||||
return client.ClientSecretPost;
|
||||
case 'client_secret_basic':
|
||||
return client.ClientSecretBasic;
|
||||
case 'client_secret_jwt':
|
||||
return client.ClientSecretJwt;
|
||||
default:
|
||||
throw new Error('Invalid client authentication method');
|
||||
}
|
||||
}
|
||||
|
||||
// Loads and configures an OIDC client to support OIDC authentication.
|
||||
// This runs under the assumption the OIDC configuration exists and is valid.
|
||||
// If it is invalid, Headplane automatically disables it.
|
||||
//
|
||||
// TODO: Support custom endpoints instead of relying on OIDC discovery.
|
||||
// This will enable us to support servers like GitHub that do not support
|
||||
// nor advertise a .well-known endpoint.
|
||||
export async function createOidcClient(
|
||||
config: NonNullable<HeadplaneConfig['oidc']>,
|
||||
) {
|
||||
// const secret = await loadClientSecret(oidc);
|
||||
const secret = config.client_secret_path
|
||||
? await loadClientSecret(config.client_secret_path)
|
||||
: config.client_secret;
|
||||
|
||||
if (!secret) {
|
||||
log.error('config', 'Missing an OIDC client secret');
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
|
||||
const oidc = await client.discovery(
|
||||
new URL(config.issuer),
|
||||
config.client_id,
|
||||
secret,
|
||||
clientAuthMethod(config.token_endpoint_auth_method)(secret),
|
||||
);
|
||||
|
||||
const metadata = oidc.serverMetadata();
|
||||
if (!metadata.authorization_endpoint) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery did not return `authorization_endpoint`',
|
||||
);
|
||||
log.error('config', 'OIDC server does not support authorization code flow');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!metadata.token_endpoint) {
|
||||
log.error('config', 'Issuer discovery did not return `token_endpoint`');
|
||||
log.error('config', 'OIDC server does not support token exchange');
|
||||
return;
|
||||
}
|
||||
|
||||
// If this field is missing, assume the server supports all response types
|
||||
// and that we can continue safely.
|
||||
if (metadata.response_types_supported) {
|
||||
if (!metadata.response_types_supported.includes('code')) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery `response_types_supported` does not include `code`',
|
||||
);
|
||||
log.error('config', 'OIDC server does not support code flow');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (metadata.token_endpoint_auth_methods_supported) {
|
||||
if (
|
||||
!metadata.token_endpoint_auth_methods_supported.includes(
|
||||
config.token_endpoint_auth_method,
|
||||
)
|
||||
) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery `token_endpoint_auth_methods_supported` does not include `%s`',
|
||||
config.token_endpoint_auth_method,
|
||||
);
|
||||
log.error(
|
||||
'config',
|
||||
'OIDC server does not support %s',
|
||||
config.token_endpoint_auth_method,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (!metadata.userinfo_endpoint) {
|
||||
log.error('config', 'Issuer discovery did not return `userinfo_endpoint`');
|
||||
log.error('config', 'OIDC server does not support userinfo endpoint');
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('config', 'OIDC client created successfully');
|
||||
log.info('config', 'Using %s as the OIDC issuer', config.issuer);
|
||||
log.debug(
|
||||
'config',
|
||||
'Authorization endpoint: %s',
|
||||
metadata.authorization_endpoint,
|
||||
);
|
||||
log.debug('config', 'Token endpoint: %s', metadata.token_endpoint);
|
||||
log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint);
|
||||
return oidc;
|
||||
}
|
||||
144
app/server/web/roles.ts
Normal file
144
app/server/web/roles.ts
Normal file
@ -0,0 +1,144 @@
|
||||
export type Capabilities = (typeof Capabilities)[keyof typeof Capabilities];
|
||||
export const Capabilities = {
|
||||
// Can access the admin console
|
||||
ui_access: 1 << 0,
|
||||
|
||||
// Read tailnet policy file (unimplemented)
|
||||
read_policy: 1 << 1,
|
||||
|
||||
// Write tailnet policy file (unimplemented)
|
||||
write_policy: 1 << 2,
|
||||
|
||||
// Read network configurations
|
||||
read_network: 1 << 3,
|
||||
|
||||
// Write network configurations, for example, enable MagicDNS, split DNS,
|
||||
// make subnet, or allow a node to be an exit node, enable HTTPS
|
||||
write_network: 1 << 4,
|
||||
|
||||
// Read feature configuration (unimplemented)
|
||||
read_feature: 1 << 5,
|
||||
|
||||
// Write feature configuration, for example, enable Taildrop (unimplemented)
|
||||
write_feature: 1 << 6,
|
||||
|
||||
// Configure user & group provisioning (unimplemented)
|
||||
configure_iam: 1 << 7,
|
||||
|
||||
// Read machines, for example, see machine names and status
|
||||
read_machines: 1 << 8,
|
||||
|
||||
// Write machines, for example, approve, rename, and remove machines
|
||||
write_machines: 1 << 9,
|
||||
|
||||
// Read users and user roles
|
||||
read_users: 1 << 10,
|
||||
|
||||
// Write users and user roles, for example, remove users,
|
||||
// approve users, make Admin
|
||||
write_users: 1 << 11,
|
||||
|
||||
// Can generate authkeys (unimplemented)
|
||||
generate_authkeys: 1 << 12,
|
||||
|
||||
// Can use any tag (without being tag owner) (unimplemented)
|
||||
use_tags: 1 << 13,
|
||||
|
||||
// Write tailnet name (unimplemented)
|
||||
write_tailnet: 1 << 14,
|
||||
|
||||
// Owner flag
|
||||
owner: 1 << 15,
|
||||
} as const;
|
||||
|
||||
export type Roles = [keyof typeof Roles];
|
||||
export const Roles = {
|
||||
owner:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.write_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.write_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.write_feature |
|
||||
Capabilities.configure_iam |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.write_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.write_users |
|
||||
Capabilities.generate_authkeys |
|
||||
Capabilities.use_tags |
|
||||
Capabilities.write_tailnet |
|
||||
Capabilities.owner,
|
||||
|
||||
admin:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.write_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.write_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.write_feature |
|
||||
Capabilities.configure_iam |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.write_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.write_users |
|
||||
Capabilities.generate_authkeys |
|
||||
Capabilities.use_tags |
|
||||
Capabilities.write_tailnet,
|
||||
|
||||
network_admin:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.write_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.write_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.generate_authkeys |
|
||||
Capabilities.use_tags |
|
||||
Capabilities.write_tailnet,
|
||||
|
||||
it_admin:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.write_feature |
|
||||
Capabilities.configure_iam |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.write_machines |
|
||||
Capabilities.read_users |
|
||||
Capabilities.write_users |
|
||||
Capabilities.generate_authkeys,
|
||||
|
||||
auditor:
|
||||
Capabilities.ui_access |
|
||||
Capabilities.read_policy |
|
||||
Capabilities.read_network |
|
||||
Capabilities.read_feature |
|
||||
Capabilities.read_machines |
|
||||
Capabilities.read_users,
|
||||
|
||||
// Default role for new users with 0 capabilities on the UI side of things
|
||||
member: 0,
|
||||
} as const;
|
||||
|
||||
export type Role = keyof typeof Roles;
|
||||
export type Capability = keyof typeof Capabilities;
|
||||
export function hasCapability(role: Role, capability: Capability): boolean {
|
||||
return (Roles[role] & Capabilities[capability]) !== 0;
|
||||
}
|
||||
|
||||
export function getRoleFromCapabilities(capabilities: Capabilities): Role {
|
||||
const iterable = Roles as Record<string, Capabilities>;
|
||||
for (const role in iterable) {
|
||||
if (iterable[role] === capabilities) {
|
||||
return role as Role;
|
||||
}
|
||||
}
|
||||
|
||||
return 'member';
|
||||
}
|
||||
304
app/server/web/sessions.ts
Normal file
304
app/server/web/sessions.ts
Normal file
@ -0,0 +1,304 @@
|
||||
import { open, readFile } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import { exit } from 'node:process';
|
||||
import {
|
||||
CookieSerializeOptions,
|
||||
Session,
|
||||
SessionStorage,
|
||||
createCookieSessionStorage,
|
||||
} from 'react-router';
|
||||
import log from '~/utils/log';
|
||||
import { Capabilities, Roles } from './roles';
|
||||
|
||||
export interface AuthSession {
|
||||
state: 'auth';
|
||||
api_key: string;
|
||||
user: {
|
||||
subject: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OidcFlowSession {
|
||||
state: 'flow';
|
||||
oidc: {
|
||||
state: string;
|
||||
nonce: string;
|
||||
code_verifier: string;
|
||||
redirect_uri: string;
|
||||
};
|
||||
}
|
||||
|
||||
type JoinedSession = AuthSession | OidcFlowSession;
|
||||
interface Error {
|
||||
error: string;
|
||||
}
|
||||
|
||||
interface CookieOptions {
|
||||
name: string;
|
||||
secure: boolean;
|
||||
maxAge: number;
|
||||
secrets: string[];
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
class Sessionizer {
|
||||
private storage: SessionStorage<JoinedSession, Error>;
|
||||
private caps: Record<string, { c: Capabilities; oo?: boolean }>;
|
||||
private capsPath?: string;
|
||||
|
||||
constructor(
|
||||
options: CookieOptions,
|
||||
caps: Record<string, { c: Capabilities; oo?: boolean }>,
|
||||
capsPath?: string,
|
||||
) {
|
||||
this.caps = caps;
|
||||
this.capsPath = capsPath;
|
||||
this.storage = createCookieSessionStorage({
|
||||
cookie: {
|
||||
...options,
|
||||
httpOnly: true,
|
||||
path: __PREFIX__, // Only match on the prefix
|
||||
sameSite: 'lax', // TODO: Strictify with Domain,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// This throws on the assumption that auth is already checked correctly
|
||||
// on something that wraps the route calling auth. The top-level routes
|
||||
// that call this are wrapped with try/catch to handle the error.
|
||||
async auth(request: Request) {
|
||||
const cookie = request.headers.get('cookie');
|
||||
const session = await this.storage.getSession(cookie);
|
||||
const type = session.get('state');
|
||||
if (!type) {
|
||||
throw new Error('Session state not found');
|
||||
}
|
||||
|
||||
if (type !== 'auth') {
|
||||
throw new Error('Session is not authenticated');
|
||||
}
|
||||
|
||||
return session as Session<AuthSession, Error>;
|
||||
}
|
||||
|
||||
roleForSubject(subject: string): keyof typeof Roles | undefined {
|
||||
const role = this.caps[subject]?.c;
|
||||
if (!role) {
|
||||
return;
|
||||
}
|
||||
|
||||
// We need this in string form based on Object.keys of the roles
|
||||
for (const [key, value] of Object.entries(Roles)) {
|
||||
if (value === role) {
|
||||
return key as keyof typeof Roles;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onboardForSubject(subject: string) {
|
||||
return this.caps[subject]?.oo ?? false;
|
||||
}
|
||||
|
||||
// Given an OR of capabilities, check if the session has the required
|
||||
// capabilities. If not, return false. Can throw since it calls auth()
|
||||
async check(request: Request, capabilities: Capabilities) {
|
||||
const session = await this.auth(request);
|
||||
const { subject } = session.get('user') ?? {};
|
||||
if (!subject) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// This is the subject we set on API key based sessions. API keys
|
||||
// inherently imply admin access so we return true for all checks.
|
||||
if (subject === 'unknown-non-oauth') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the role does not exist, then this is a new subject that we have
|
||||
// not seen before. Since this is new, we set access to the lowest
|
||||
// level by default which is the member role.
|
||||
//
|
||||
// This also allows us to avoid configuring preventing sign ups with
|
||||
// OIDC, since the default sign up logic gives member which does not
|
||||
// have access to the UI whatsoever.
|
||||
const role = this.caps[subject];
|
||||
if (!role) {
|
||||
const memberRole = await this.registerSubject(subject);
|
||||
return (capabilities & memberRole.c) === capabilities;
|
||||
}
|
||||
|
||||
return (capabilities & role.c) === capabilities;
|
||||
}
|
||||
|
||||
async checkSubject(subject: string, capabilities: Capabilities) {
|
||||
// This is the subject we set on API key based sessions. API keys
|
||||
// inherently imply admin access so we return true for all checks.
|
||||
if (subject === 'unknown-non-oauth') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If the role does not exist, then this is a new subject that we have
|
||||
// not seen before. Since this is new, we set access to the lowest
|
||||
// level by default which is the member role.
|
||||
//
|
||||
// This also allows us to avoid configuring preventing sign ups with
|
||||
// OIDC, since the default sign up logic gives member which does not
|
||||
// have access to the UI whatsoever.
|
||||
const role = this.caps[subject];
|
||||
if (!role) {
|
||||
const memberRole = await this.registerSubject(subject);
|
||||
return (capabilities & memberRole.c) === capabilities;
|
||||
}
|
||||
|
||||
return (capabilities & role.c) === capabilities;
|
||||
}
|
||||
|
||||
// This code is very simple, if the user does not exist in the database
|
||||
// file then we register it with the lowest level of access. If the user
|
||||
// database is empty, the first user to sign in will be given the owner
|
||||
// role.
|
||||
private async registerSubject(subject: string) {
|
||||
if (this.caps[subject]) {
|
||||
return this.caps[subject];
|
||||
}
|
||||
|
||||
if (Object.keys(this.caps).length === 0) {
|
||||
log.debug('auth', 'First user registered as owner: %s', subject);
|
||||
this.caps[subject] = { c: Roles.owner };
|
||||
await this.flushUserDatabase();
|
||||
return this.caps[subject];
|
||||
}
|
||||
|
||||
log.debug('auth', 'New user registered as member: %s', subject);
|
||||
this.caps[subject] = { c: Roles.member };
|
||||
await this.flushUserDatabase();
|
||||
return this.caps[subject];
|
||||
}
|
||||
|
||||
private async flushUserDatabase() {
|
||||
if (!this.capsPath) {
|
||||
return;
|
||||
}
|
||||
|
||||
const data = Object.entries(this.caps).map(([u, { c, oo }]) => ({
|
||||
u,
|
||||
c,
|
||||
oo,
|
||||
}));
|
||||
try {
|
||||
const handle = await open(this.capsPath, 'w');
|
||||
await handle.write(JSON.stringify(data));
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
log.error('config', 'Error writing user database file: %s', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Updates the capabilities and roles of a subject
|
||||
async reassignSubject(subject: string, role: keyof typeof Roles) {
|
||||
// Check if we are owner
|
||||
if (this.roleForSubject(subject) === 'owner') {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.caps[subject] = {
|
||||
...this.caps[subject], // Preserve the existing capabilities if any
|
||||
c: Roles[role],
|
||||
};
|
||||
|
||||
await this.flushUserDatabase();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Overrides the onboarding status for a subject
|
||||
async overrideOnboarding(subject: string, onboarding: boolean) {
|
||||
this.caps[subject] = {
|
||||
...this.caps[subject], // Preserve the existing capabilities if any
|
||||
oo: onboarding,
|
||||
};
|
||||
await this.flushUserDatabase();
|
||||
}
|
||||
|
||||
getOrCreate<T extends JoinedSession = AuthSession>(request: Request) {
|
||||
return this.storage.getSession(request.headers.get('cookie')) as Promise<
|
||||
Session<T, Error>
|
||||
>;
|
||||
}
|
||||
|
||||
destroy(session: Session) {
|
||||
return this.storage.destroySession(session);
|
||||
}
|
||||
|
||||
commit(session: Session, options?: CookieSerializeOptions) {
|
||||
return this.storage.commitSession(session, options);
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSessionStorage(
|
||||
options: CookieOptions,
|
||||
usersPath?: string,
|
||||
) {
|
||||
const map: Record<
|
||||
string,
|
||||
{
|
||||
c: number;
|
||||
oo?: boolean;
|
||||
}
|
||||
> = {};
|
||||
if (usersPath) {
|
||||
// We need to load our users from the file (default to empty map)
|
||||
// We then translate each user into a capability object using the helper
|
||||
// method defined in the roles.ts file
|
||||
const data = await loadUserFile(usersPath);
|
||||
log.debug('config', 'Loaded %d users from database', data.length);
|
||||
|
||||
for (const user of data) {
|
||||
map[user.u] = {
|
||||
c: user.c,
|
||||
oo: user.oo,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return new Sessionizer(options, map, usersPath);
|
||||
}
|
||||
|
||||
async function loadUserFile(path: string) {
|
||||
const realPath = resolve(path);
|
||||
|
||||
try {
|
||||
const handle = await open(realPath, 'a+');
|
||||
log.info('config', 'Using user database file at %s', realPath);
|
||||
await handle.close();
|
||||
} catch (error) {
|
||||
log.info('config', 'User database file not accessible at %s', realPath);
|
||||
log.debug('config', 'Error details: %s', error);
|
||||
exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await readFile(realPath, 'utf8');
|
||||
const users = JSON.parse(data.trim()) as {
|
||||
u?: string;
|
||||
c?: number;
|
||||
oo?: boolean;
|
||||
}[];
|
||||
|
||||
// Never trust user input
|
||||
return users.filter(
|
||||
(user) => user.u !== undefined && user.c !== undefined,
|
||||
) as {
|
||||
u: string;
|
||||
c: number;
|
||||
oo?: boolean;
|
||||
}[];
|
||||
} catch (error) {
|
||||
log.debug('config', 'Error reading user database file: %s', error);
|
||||
log.debug('config', 'Using empty user database');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@ -1,204 +0,0 @@
|
||||
import { constants, access, readFile, writeFile } from 'node:fs/promises';
|
||||
import { Document, parseDocument } from 'yaml';
|
||||
import { hp_getIntegration } from '~/utils/integration/loader';
|
||||
import mutex from '~/utils/mutex';
|
||||
import { hp_getConfig } from '~server/context/global';
|
||||
import log from '~server/utils/log';
|
||||
import { HeadscaleConfig, validateConfig } from './parser';
|
||||
|
||||
let runtimeYaml: Document | undefined = undefined;
|
||||
let runtimeConfig: HeadscaleConfig | undefined = undefined;
|
||||
let runtimePath: string | undefined = undefined;
|
||||
let runtimeMode: 'rw' | 'ro' | 'no' = 'no';
|
||||
const runtimeLock = mutex();
|
||||
|
||||
export type ConfigModes =
|
||||
| {
|
||||
mode: 'rw' | 'ro';
|
||||
config: HeadscaleConfig;
|
||||
}
|
||||
| {
|
||||
mode: 'no';
|
||||
config: undefined;
|
||||
};
|
||||
|
||||
export function hs_getConfig(): ConfigModes {
|
||||
if (runtimeMode === 'no') {
|
||||
return {
|
||||
mode: 'no',
|
||||
config: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
runtimeLock.acquire();
|
||||
// We can assert if mode is not 'no'
|
||||
const config = runtimeConfig!;
|
||||
runtimeLock.release();
|
||||
|
||||
return {
|
||||
mode: runtimeMode,
|
||||
config: config,
|
||||
};
|
||||
}
|
||||
|
||||
export async function hs_loadConfig(path?: string, strict?: boolean) {
|
||||
if (runtimeConfig !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeLock.acquire();
|
||||
if (!path) {
|
||||
runtimeLock.release();
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeMode = await validateConfigPath(path);
|
||||
if (runtimeMode === 'no') {
|
||||
runtimeLock.release();
|
||||
return;
|
||||
}
|
||||
|
||||
runtimePath = path;
|
||||
const rawConfig = await loadConfigFile(path);
|
||||
if (!rawConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
const config = validateConfig(rawConfig, strict ?? true);
|
||||
if (!config) {
|
||||
runtimeMode = 'no';
|
||||
}
|
||||
|
||||
runtimeConfig = config;
|
||||
}
|
||||
|
||||
async function validateConfigPath(path: string) {
|
||||
log.debug('CFGX', `Validating Headscale configuration file at ${path}`);
|
||||
try {
|
||||
await access(path, constants.F_OK | constants.R_OK);
|
||||
log.info('CFGX', `Headscale configuration found at ${path}`);
|
||||
} catch (e) {
|
||||
log.error('CFGX', `Headscale configuration not readable at ${path}`);
|
||||
log.error('CFGX', `${e}`);
|
||||
return 'no';
|
||||
}
|
||||
|
||||
let writeable = false;
|
||||
try {
|
||||
await access(path, constants.W_OK);
|
||||
writeable = true;
|
||||
} catch (e) {
|
||||
log.warn('CFGX', `Headscale configuration not writeable at ${path}`);
|
||||
log.debug('CFGX', `${e}`);
|
||||
}
|
||||
|
||||
return writeable ? 'rw' : 'ro';
|
||||
}
|
||||
|
||||
async function loadConfigFile(path: string) {
|
||||
log.debug('CFGX', `Loading Headscale configuration file at ${path}`);
|
||||
try {
|
||||
const data = await readFile(path, 'utf8');
|
||||
const configYaml = parseDocument(data);
|
||||
|
||||
if (configYaml.errors.length > 0) {
|
||||
log.error(
|
||||
'CFGX',
|
||||
`Error parsing Headscale configuration file at ${path}`,
|
||||
);
|
||||
for (const error of configYaml.errors) {
|
||||
log.error('CFGX', ` ${error.toString()}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeYaml = configYaml;
|
||||
return configYaml.toJSON() as unknown;
|
||||
} catch (e) {
|
||||
log.error('CFGX', `Error reading Headscale configuration file at ${path}`);
|
||||
log.error('CFGX', `${e}`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
type PatchConfig = { path: string; value: unknown };
|
||||
export async function hs_patchConfig(patches: PatchConfig[]) {
|
||||
if (!runtimeConfig || !runtimeYaml || !runtimePath) {
|
||||
log.error('CFGX', 'Headscale configuration not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
if (runtimeMode === 'no') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (runtimeMode === 'ro') {
|
||||
throw new Error('Headscale configuration is read-only');
|
||||
}
|
||||
|
||||
runtimeLock.acquire();
|
||||
const config = runtimeConfig!;
|
||||
|
||||
log.debug('CFGX', 'Patching Headscale configuration');
|
||||
for (const patch of patches) {
|
||||
const { path, value } = patch;
|
||||
log.debug('CFGX', 'Patching %s in Headscale configuration', path);
|
||||
// If the key is something like `test.bar."foo.bar"`, then we treat
|
||||
// the foo.bar as a single key, and not as two keys, so that needs
|
||||
// to be split correctly.
|
||||
|
||||
// Iterate through each character, and if we find a dot, we check if
|
||||
// the next character is a quote, and if it is, we skip until the next
|
||||
// quote, and then we skip the next character, which should be a dot.
|
||||
// If it's not a quote, we split it.
|
||||
const key = [];
|
||||
let current = '';
|
||||
let quote = false;
|
||||
|
||||
for (const char of path) {
|
||||
if (char === '"') {
|
||||
quote = !quote;
|
||||
}
|
||||
|
||||
if (char === '.' && !quote) {
|
||||
key.push(current);
|
||||
current = '';
|
||||
continue;
|
||||
}
|
||||
|
||||
current += char;
|
||||
}
|
||||
|
||||
key.push(current.replaceAll('"', ''));
|
||||
|
||||
// Deletion handling
|
||||
if (value === null) {
|
||||
runtimeYaml.deleteIn(key);
|
||||
continue;
|
||||
}
|
||||
|
||||
runtimeYaml.setIn(key, value);
|
||||
}
|
||||
|
||||
// Revalidate the configuration
|
||||
const context = hp_getConfig();
|
||||
const newRawConfig = runtimeYaml.toJSON() as unknown;
|
||||
runtimeConfig = context.headscale.config_strict
|
||||
? validateConfig(newRawConfig, true)
|
||||
: (newRawConfig as HeadscaleConfig);
|
||||
|
||||
log.debug(
|
||||
'CFGX',
|
||||
'Writing patched Headscale configuration to %s',
|
||||
runtimePath,
|
||||
);
|
||||
await writeFile(runtimePath, runtimeYaml.toString(), 'utf8');
|
||||
runtimeLock.release();
|
||||
}
|
||||
|
||||
// IMPORTANT THIS IS A SIDE EFFECT ON INIT
|
||||
// TODO: Replace this into the new singleton system
|
||||
const context = hp_getConfig();
|
||||
hs_loadConfig(context.headscale.config_path, context.headscale.config_strict);
|
||||
hp_getIntegration();
|
||||
@ -1,149 +0,0 @@
|
||||
import { request } from 'undici';
|
||||
import { hp_getConfig, hp_getSingleton } from '~server/context/global';
|
||||
import log from '~server/utils/log';
|
||||
|
||||
export class HeadscaleError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(message: string, status: number) {
|
||||
super(message);
|
||||
this.name = 'HeadscaleError';
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export class FatalError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
'The Headscale server is not accessible or the supplied API key is invalid',
|
||||
);
|
||||
this.name = 'FatalError';
|
||||
}
|
||||
}
|
||||
|
||||
export async function healthcheck() {
|
||||
log.debug('APIC', 'GET /health');
|
||||
const health = new URL('health', hp_getConfig().headscale.url);
|
||||
const response = await request(health.toString(), {
|
||||
dispatcher: hp_getSingleton('api_agent'),
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Intentionally not catching
|
||||
return response.statusCode === 200;
|
||||
}
|
||||
|
||||
export async function pull<T>(url: string, key: string) {
|
||||
if (!key || key === 'undefined' || key.length === 0) {
|
||||
throw new Error('Missing API key, could this be a cookie setting issue?');
|
||||
}
|
||||
|
||||
const prefix = hp_getConfig().headscale.url;
|
||||
log.debug('APIC', 'GET %s', `${prefix}/api/${url}`);
|
||||
const response = await request(`${prefix}/api/${url}`, {
|
||||
dispatcher: hp_getSingleton('api_agent'),
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.statusCode >= 400) {
|
||||
log.debug(
|
||||
'APIC',
|
||||
'GET %s failed with status %d',
|
||||
`${prefix}/api/${url}`,
|
||||
response.statusCode,
|
||||
);
|
||||
throw new HeadscaleError(await response.body.text(), response.statusCode);
|
||||
}
|
||||
|
||||
return response.body.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function post<T>(url: string, key: string, body?: unknown) {
|
||||
if (!key || key === 'undefined' || key.length === 0) {
|
||||
throw new Error('Missing API key, could this be a cookie setting issue?');
|
||||
}
|
||||
|
||||
const prefix = hp_getConfig().headscale.url;
|
||||
log.debug('APIC', 'POST %s', `${prefix}/api/${url}`);
|
||||
const response = await request(`${prefix}/api/${url}`, {
|
||||
dispatcher: hp_getSingleton('api_agent'),
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.statusCode >= 400) {
|
||||
log.debug(
|
||||
'APIC',
|
||||
'POST %s failed with status %d',
|
||||
`${prefix}/api/${url}`,
|
||||
response.statusCode,
|
||||
);
|
||||
throw new HeadscaleError(await response.body.text(), response.statusCode);
|
||||
}
|
||||
|
||||
return response.body.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function put<T>(url: string, key: string, body?: unknown) {
|
||||
if (!key || key === 'undefined' || key.length === 0) {
|
||||
throw new Error('Missing API key, could this be a cookie setting issue?');
|
||||
}
|
||||
|
||||
const prefix = hp_getConfig().headscale.url;
|
||||
log.debug('APIC', 'PUT %s', `${prefix}/api/${url}`);
|
||||
const response = await request(`${prefix}/api/${url}`, {
|
||||
dispatcher: hp_getSingleton('api_agent'),
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.statusCode >= 400) {
|
||||
log.debug(
|
||||
'APIC',
|
||||
'PUT %s failed with status %d',
|
||||
`${prefix}/api/${url}`,
|
||||
response.statusCode,
|
||||
);
|
||||
throw new HeadscaleError(await response.body.text(), response.statusCode);
|
||||
}
|
||||
|
||||
return response.body.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function del<T>(url: string, key: string) {
|
||||
if (!key || key === 'undefined' || key.length === 0) {
|
||||
throw new Error('Missing API key, could this be a cookie setting issue?');
|
||||
}
|
||||
|
||||
const prefix = hp_getConfig().headscale.url;
|
||||
log.debug('APIC', 'DELETE %s', `${prefix}/api/${url}`);
|
||||
const response = await request(`${prefix}/api/${url}`, {
|
||||
dispatcher: hp_getSingleton('api_agent'),
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
Authorization: `Bearer ${key}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.statusCode >= 400) {
|
||||
log.debug(
|
||||
'APIC',
|
||||
'DELETE %s failed with status %d',
|
||||
`${prefix}/api/${url}`,
|
||||
response.statusCode,
|
||||
);
|
||||
throw new HeadscaleError(await response.body.text(), response.statusCode);
|
||||
}
|
||||
|
||||
return response.body.json() as Promise<T>;
|
||||
}
|
||||
@ -1,142 +0,0 @@
|
||||
import { constants, access } from 'node:fs/promises';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { Client } from 'undici';
|
||||
import { HeadscaleError, healthcheck, pull } from '~/utils/headscale';
|
||||
import { HeadplaneConfig } from '~server/context/parser';
|
||||
import log from '~server/utils/log';
|
||||
import { Integration } from './abstract';
|
||||
|
||||
type T = NonNullable<HeadplaneConfig['integration']>['docker'];
|
||||
export default class DockerIntegration extends Integration<T> {
|
||||
private maxAttempts = 10;
|
||||
private client: Client | undefined;
|
||||
|
||||
get name() {
|
||||
return 'Docker';
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
if (this.context.container_name.length === 0) {
|
||||
log.error('INTG', 'Docker container name is empty');
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info('INTG', 'Using container: %s', this.context.container_name);
|
||||
let url: URL | undefined;
|
||||
try {
|
||||
url = new URL(this.context.socket);
|
||||
} catch {
|
||||
log.error('INTG', 'Invalid Docker socket path: %s', this.context.socket);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.protocol !== 'tcp:' && url.protocol !== 'unix:') {
|
||||
log.error('INTG', 'Invalid Docker socket protocol: %s', url.protocol);
|
||||
return false;
|
||||
}
|
||||
|
||||
// The API is available as an HTTP endpoint and this
|
||||
// will simplify the fetching logic in undici
|
||||
if (url.protocol === 'tcp:') {
|
||||
// Apparently setting url.protocol doesn't work anymore?
|
||||
const fetchU = url.href.replace(url.protocol, 'http:');
|
||||
|
||||
try {
|
||||
log.info('INTG', 'Checking API: %s', fetchU);
|
||||
await fetch(new URL('/v1.30/version', fetchU).href);
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to connect to Docker API: %s', error);
|
||||
log.debug('INTG', 'Connection error: %o', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.client = new Client(fetchU);
|
||||
}
|
||||
|
||||
// Check if the socket is accessible
|
||||
if (url.protocol === 'unix:') {
|
||||
try {
|
||||
log.info('INTG', 'Checking socket: %s', url.pathname);
|
||||
await access(url.pathname, constants.R_OK);
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to access Docker socket: %s', url.pathname);
|
||||
log.debug('INTG', 'Access error: %o', error);
|
||||
return false;
|
||||
}
|
||||
|
||||
this.client = new Client('http://localhost', {
|
||||
socketPath: url.pathname,
|
||||
});
|
||||
}
|
||||
|
||||
return this.client !== undefined;
|
||||
}
|
||||
|
||||
async onConfigChange() {
|
||||
if (!this.client) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('INTG', 'Restarting Headscale via Docker');
|
||||
|
||||
let attempts = 0;
|
||||
while (attempts <= this.maxAttempts) {
|
||||
log.debug(
|
||||
'INTG',
|
||||
'Restarting container: %s (attempt %d)',
|
||||
this.context.container_name,
|
||||
attempts,
|
||||
);
|
||||
|
||||
const response = await this.client.request({
|
||||
method: 'POST',
|
||||
path: `/v1.30/containers/${this.context.container_name}/restart`,
|
||||
});
|
||||
|
||||
if (response.statusCode !== 204) {
|
||||
if (attempts < this.maxAttempts) {
|
||||
attempts++;
|
||||
await setTimeout(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
const stringCode = response.statusCode.toString();
|
||||
const body = await response.body.text();
|
||||
throw new Error(`API request failed: ${stringCode} ${body}`);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
attempts = 0;
|
||||
while (attempts <= this.maxAttempts) {
|
||||
try {
|
||||
log.debug('INTG', 'Checking Headscale status (attempt %d)', attempts);
|
||||
await healthcheck();
|
||||
log.info('INTG', 'Headscale is up and running');
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error instanceof HeadscaleError && error.status === 401) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (error instanceof HeadscaleError && error.status === 404) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (attempts < this.maxAttempts) {
|
||||
attempts++;
|
||||
await setTimeout(1000);
|
||||
continue;
|
||||
}
|
||||
|
||||
log.error(
|
||||
'INTG',
|
||||
'Missed restart deadline for %s',
|
||||
this.context.container_name,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,72 +0,0 @@
|
||||
import { hp_getConfig } from '~server/context/global';
|
||||
import { HeadplaneConfig } from '~server/context/parser';
|
||||
import log from '~server/utils/log';
|
||||
import { Integration } from './abstract';
|
||||
import dockerIntegration from './docker';
|
||||
import kubernetesIntegration from './kubernetes';
|
||||
import procIntegration from './proc';
|
||||
|
||||
let runtimeIntegration: Integration<unknown> | undefined = undefined;
|
||||
|
||||
export function hp_getIntegration() {
|
||||
return runtimeIntegration;
|
||||
}
|
||||
|
||||
export async function hp_loadIntegration(
|
||||
context: HeadplaneConfig['integration'],
|
||||
) {
|
||||
const integration = getIntegration(context);
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await integration.isAvailable();
|
||||
if (!res) {
|
||||
log.error('INTG', 'Integration %s is not available', integration);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('INTG', 'Failed to load integration %s: %s', integration, error);
|
||||
log.debug('INTG', 'Loading error: %o', error);
|
||||
return;
|
||||
}
|
||||
|
||||
runtimeIntegration = integration;
|
||||
}
|
||||
|
||||
function getIntegration(integration: HeadplaneConfig['integration']) {
|
||||
const docker = integration?.docker;
|
||||
const k8s = integration?.kubernetes;
|
||||
const proc = integration?.proc;
|
||||
|
||||
if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) {
|
||||
log.debug('INTG', 'No integrations enabled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (docker?.enabled && k8s?.enabled && proc?.enabled) {
|
||||
log.error('INTG', 'Multiple integrations enabled, please pick one only');
|
||||
return;
|
||||
}
|
||||
|
||||
if (docker?.enabled) {
|
||||
log.info('INTG', 'Using Docker integration');
|
||||
return new dockerIntegration(integration?.docker);
|
||||
}
|
||||
|
||||
if (k8s?.enabled) {
|
||||
log.info('INTG', 'Using Kubernetes integration');
|
||||
return new kubernetesIntegration(integration?.kubernetes);
|
||||
}
|
||||
|
||||
if (proc?.enabled) {
|
||||
log.info('INTG', 'Using Proc integration');
|
||||
return new procIntegration(integration?.proc);
|
||||
}
|
||||
}
|
||||
|
||||
// IMPORTANT THIS IS A SIDE EFFECT ON INIT
|
||||
// TODO: Switch this to the new singleton system
|
||||
const context = hp_getConfig();
|
||||
hp_loadIntegration(context.integration);
|
||||
67
app/utils/live-data.tsx
Normal file
67
app/utils/live-data.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { useInterval } from 'usehooks-ts';
|
||||
|
||||
const LiveDataPausedContext = createContext({
|
||||
paused: false,
|
||||
setPaused: (_: boolean) => {},
|
||||
});
|
||||
|
||||
interface LiveDataProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LiveDataProvider({ children }: LiveDataProps) {
|
||||
const [paused, setPaused] = useState(false);
|
||||
const revalidator = useRevalidator();
|
||||
|
||||
// Document is marked as optional here because it's not available in SSR
|
||||
// The optional chain means if document is not defined, visible is false
|
||||
const [visible, setVisible] = useState(
|
||||
() =>
|
||||
typeof document !== 'undefined' && document.visibilityState === 'visible',
|
||||
);
|
||||
|
||||
// Function to revalidate safely
|
||||
const revalidateIfIdle = () => {
|
||||
if (revalidator.state === 'idle') {
|
||||
revalidator.revalidate();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
setVisible(document.visibilityState === 'visible');
|
||||
if (!paused && document.visibilityState === 'visible') {
|
||||
revalidateIfIdle();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('online', revalidateIfIdle);
|
||||
document.addEventListener('focus', revalidateIfIdle);
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', revalidateIfIdle);
|
||||
document.removeEventListener('focus', revalidateIfIdle);
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [paused, revalidator]);
|
||||
|
||||
// Poll only when visible and not paused
|
||||
useInterval(revalidateIfIdle, visible && !paused ? 3000 : null);
|
||||
|
||||
return (
|
||||
<LiveDataPausedContext.Provider value={{ paused, setPaused }}>
|
||||
{children}
|
||||
</LiveDataPausedContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLiveData() {
|
||||
const context = useContext(LiveDataPausedContext);
|
||||
return {
|
||||
pause: () => context.setPaused(true),
|
||||
resume: () => context.setPaused(false),
|
||||
};
|
||||
}
|
||||
31
app/utils/log.ts
Normal file
31
app/utils/log.ts
Normal file
@ -0,0 +1,31 @@
|
||||
// MARK: Side-Effects
|
||||
// This module contains a side-effect because everything running here
|
||||
// is static and logger is later modified in `app/server/index.ts` to
|
||||
// disable debug logging if the `HEADPLANE_DEBUG_LOG` specifies as such.
|
||||
|
||||
const levels = ['info', 'warn', 'error', 'debug'] as const;
|
||||
type Category = 'server' | 'config' | 'agent' | 'api' | 'auth';
|
||||
|
||||
export interface Logger
|
||||
extends Record<
|
||||
(typeof levels)[number],
|
||||
(category: Category, message: string, ...args: unknown[]) => void
|
||||
> {
|
||||
debugEnabled: boolean;
|
||||
}
|
||||
|
||||
export default {
|
||||
debugEnabled: true,
|
||||
...Object.fromEntries(
|
||||
levels.map((level) => [
|
||||
level,
|
||||
(category: Category, message: string, ...args: unknown[]) => {
|
||||
const date = new Date().toISOString();
|
||||
console.log(
|
||||
`${date} [${category}] ${level.toUpperCase()}: ${message}`,
|
||||
...args,
|
||||
);
|
||||
},
|
||||
]),
|
||||
),
|
||||
} as Logger;
|
||||
@ -1,32 +0,0 @@
|
||||
class Mutex {
|
||||
private locked = false;
|
||||
private queue: (() => void)[] = [];
|
||||
|
||||
constructor(locked: boolean) {
|
||||
this.locked = locked;
|
||||
}
|
||||
|
||||
acquire() {
|
||||
return new Promise<void>((resolve) => {
|
||||
if (!this.locked) {
|
||||
this.locked = true;
|
||||
resolve();
|
||||
} else {
|
||||
this.queue.push(resolve);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
release() {
|
||||
if (this.queue.length > 0) {
|
||||
const next = this.queue.shift();
|
||||
next?.();
|
||||
} else {
|
||||
this.locked = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default function mutex(locked = false) {
|
||||
return new Mutex(locked);
|
||||
}
|
||||
@ -1,13 +1,6 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import * as client from 'openid-client';
|
||||
import { hp_getSingleton, hp_setSingleton } from '~server/context/global';
|
||||
import { HeadplaneConfig } from '~server/context/parser';
|
||||
import log from '~server/utils/log';
|
||||
|
||||
type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
|
||||
declare global {
|
||||
const __PREFIX__: string;
|
||||
}
|
||||
import { Configuration, IDToken, UserInfoResponse } from 'openid-client';
|
||||
import log from '~/utils/log';
|
||||
|
||||
// We try our best to infer the callback URI of our Headplane instance
|
||||
// By default it is always /<base_path>/oidc/callback
|
||||
@ -21,15 +14,15 @@ export function getRedirectUri(req: Request) {
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
log.error('OIDC', 'Unable to find a host header');
|
||||
log.error('OIDC', 'Ensure either Host or X-Forwarded-Host is set');
|
||||
log.error('auth', 'Unable to find a host header');
|
||||
log.error('auth', 'Ensure either Host or X-Forwarded-Host is set');
|
||||
throw new Error('Could not determine reverse proxy host');
|
||||
}
|
||||
|
||||
const proto = req.headers.get('X-Forwarded-Proto');
|
||||
if (!proto) {
|
||||
log.warn('OIDC', 'No X-Forwarded-Proto header found');
|
||||
log.warn('OIDC', 'Assuming your Headplane instance runs behind HTTP');
|
||||
log.warn('auth', 'No X-Forwarded-Proto header found');
|
||||
log.warn('auth', 'Assuming your Headplane instance runs behind HTTP');
|
||||
}
|
||||
|
||||
url.protocol = proto ?? 'http:';
|
||||
@ -37,74 +30,11 @@ export function getRedirectUri(req: Request) {
|
||||
return url.href;
|
||||
}
|
||||
|
||||
let oidcSecret: string | undefined = undefined;
|
||||
export function getOidcSecret() {
|
||||
return oidcSecret;
|
||||
}
|
||||
|
||||
async function resolveClientSecret(oidc: OidcConfig) {
|
||||
if (!oidc.client_secret && !oidc.client_secret_path) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (oidc.client_secret_path) {
|
||||
// We need to interpolate environment variables into the path
|
||||
// Path formatting can be like ${ENV_NAME}/path/to/secret
|
||||
let path = oidc.client_secret_path;
|
||||
const matches = path.match(/\${(.*?)}/g);
|
||||
|
||||
if (matches) {
|
||||
for (const match of matches) {
|
||||
const env = match.slice(2, -1);
|
||||
const value = process.env[env];
|
||||
if (!value) {
|
||||
log.error('CFGX', 'Environment variable %s is not set', env);
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('CFGX', 'Interpolating %s with %s', match, value);
|
||||
path = path.replace(match, value);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug('CFGX', 'Reading client secret from %s', path);
|
||||
const secret = await readFile(path, 'utf-8');
|
||||
if (secret.trim().length === 0) {
|
||||
log.error('CFGX', 'Empty OIDC client secret');
|
||||
return;
|
||||
}
|
||||
|
||||
oidcSecret = secret;
|
||||
} catch (error) {
|
||||
log.error('CFGX', 'Failed to read client secret from %s', path);
|
||||
log.error('CFGX', 'Error: %s', error);
|
||||
log.debug('CFGX', 'Error details: %o', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (oidc.client_secret) {
|
||||
oidcSecret = oidc.client_secret;
|
||||
}
|
||||
}
|
||||
|
||||
function clientAuthMethod(
|
||||
method: string,
|
||||
): (secret: string) => client.ClientAuth {
|
||||
switch (method) {
|
||||
case 'client_secret_post':
|
||||
return client.ClientSecretPost;
|
||||
case 'client_secret_basic':
|
||||
return client.ClientSecretBasic;
|
||||
case 'client_secret_jwt':
|
||||
return client.ClientSecretJwt;
|
||||
default:
|
||||
throw new Error('Invalid client authentication method');
|
||||
}
|
||||
}
|
||||
|
||||
export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
|
||||
const config = hp_getSingleton('oidc_client');
|
||||
export async function beginAuthFlow(
|
||||
config: Configuration,
|
||||
redirect_uri: string,
|
||||
token_endpoint_auth_method: string,
|
||||
) {
|
||||
const codeVerifier = client.randomPKCECodeVerifier();
|
||||
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
|
||||
|
||||
@ -113,7 +43,7 @@ export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
|
||||
scope: 'openid profile email',
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
token_endpoint_auth_method: oidc.token_endpoint_auth_method,
|
||||
token_endpoint_auth_method,
|
||||
state: client.randomState(),
|
||||
};
|
||||
|
||||
@ -134,18 +64,20 @@ export async function beginAuthFlow(oidc: OidcConfig, redirect_uri: string) {
|
||||
|
||||
interface FlowOptions {
|
||||
redirect_uri: string;
|
||||
codeVerifier: string;
|
||||
code_verifier: string;
|
||||
state: string;
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
|
||||
const config = hp_getSingleton('oidc_client');
|
||||
export async function finishAuthFlow(
|
||||
config: Configuration,
|
||||
options: FlowOptions,
|
||||
) {
|
||||
const tokens = await client.authorizationCodeGrant(
|
||||
config,
|
||||
new URL(options.redirect_uri),
|
||||
{
|
||||
pkceCodeVerifier: options.codeVerifier,
|
||||
pkceCodeVerifier: options.code_verifier,
|
||||
expectedNonce: options.nonce,
|
||||
expectedState: options.state,
|
||||
idTokenExpected: true,
|
||||
@ -167,11 +99,34 @@ export async function finishAuthFlow(oidc: OidcConfig, options: FlowOptions) {
|
||||
subject: user.sub,
|
||||
name: getName(user, claims),
|
||||
email: user.email ?? claims.email?.toString(),
|
||||
username: user.preferred_username ?? claims.preferred_username?.toString(),
|
||||
username: calculateUsername(claims, user),
|
||||
picture: user.picture,
|
||||
};
|
||||
}
|
||||
|
||||
function calculateUsername(claims: IDToken, user: UserInfoResponse) {
|
||||
if (user.preferred_username) {
|
||||
return user.preferred_username;
|
||||
}
|
||||
|
||||
if (
|
||||
claims.preferred_username &&
|
||||
typeof claims.preferred_username === 'string'
|
||||
) {
|
||||
return claims.preferred_username;
|
||||
}
|
||||
|
||||
if (user.email) {
|
||||
return user.email.split('@')[0];
|
||||
}
|
||||
|
||||
if (claims.email && typeof claims.email === 'string') {
|
||||
return claims.email.split('@')[0];
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
function getName(user: client.UserInfoResponse, claims: client.IDToken) {
|
||||
if (user.name) {
|
||||
return user.name;
|
||||
@ -231,7 +186,7 @@ export function formatError(error: unknown) {
|
||||
};
|
||||
}
|
||||
|
||||
log.error('OIDC', 'Unknown error: %s', error);
|
||||
log.error('auth', 'Unknown error: %s', error);
|
||||
return {
|
||||
code: 500,
|
||||
error: {
|
||||
@ -240,60 +195,3 @@ export function formatError(error: unknown) {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function testOidc(oidc: OidcConfig) {
|
||||
await resolveClientSecret(oidc);
|
||||
if (!oidcSecret) {
|
||||
log.debug(
|
||||
'OIDC',
|
||||
'Cannot validate OIDC configuration without a client secret',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug('OIDC', 'Discovering OIDC configuration from %s', oidc.issuer);
|
||||
const secret = await resolveClientSecret(oidc);
|
||||
const config = await client.discovery(
|
||||
new URL(oidc.issuer),
|
||||
oidc.client_id,
|
||||
oidc.client_secret,
|
||||
clientAuthMethod(oidc.token_endpoint_auth_method)(oidcSecret),
|
||||
);
|
||||
|
||||
const meta = config.serverMetadata();
|
||||
if (meta.authorization_endpoint === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
log.debug('OIDC', 'Authorization endpoint: %s', meta.authorization_endpoint);
|
||||
log.debug('OIDC', 'Token endpoint: %s', meta.token_endpoint);
|
||||
|
||||
if (meta.response_types_supported) {
|
||||
if (meta.response_types_supported.includes('code') === false) {
|
||||
log.error('OIDC', 'OIDC server does not support code flow');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
log.warn('OIDC', 'OIDC server does not advertise response_types_supported');
|
||||
}
|
||||
|
||||
if (meta.token_endpoint_auth_methods_supported) {
|
||||
if (
|
||||
meta.token_endpoint_auth_methods_supported.includes(
|
||||
oidc.token_endpoint_auth_method,
|
||||
) === false
|
||||
) {
|
||||
log.error(
|
||||
'OIDC',
|
||||
'OIDC server does not support %s',
|
||||
oidc.token_endpoint_auth_method,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('OIDC', 'OIDC configuration is valid');
|
||||
hp_setSingleton('oidc_client', config);
|
||||
return true;
|
||||
}
|
||||
|
||||
@ -7,3 +7,33 @@ export function send<T>(payload: T, init?: number | ResponseInit) {
|
||||
export function send401<T>(payload: T) {
|
||||
return data(payload, { status: 401 });
|
||||
}
|
||||
|
||||
export function data400(message: string) {
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
message,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
export function data403(message: string) {
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
message,
|
||||
},
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
export function data404(message: string) {
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
message,
|
||||
},
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,63 +0,0 @@
|
||||
import { Session, createCookieSessionStorage } from 'react-router';
|
||||
import { hp_getConfig } from '~server/context/global';
|
||||
|
||||
export type SessionData = {
|
||||
hsApiKey: string;
|
||||
oidc_state: string;
|
||||
oidc_code_verif: string;
|
||||
oidc_nonce: string;
|
||||
oidc_redirect_uri: string;
|
||||
agent_onboarding: boolean;
|
||||
user: {
|
||||
subject: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
username?: string;
|
||||
picture?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type SessionFlashData = {
|
||||
error: string;
|
||||
};
|
||||
|
||||
// TODO: Domain config in cookies
|
||||
// TODO: Move this to the singleton system
|
||||
const context = hp_getConfig();
|
||||
const sessionStorage = createCookieSessionStorage<
|
||||
SessionData,
|
||||
SessionFlashData
|
||||
>({
|
||||
cookie: {
|
||||
name: 'hp_sess',
|
||||
httpOnly: true,
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
path: '/',
|
||||
sameSite: 'lax',
|
||||
secrets: [context.server.cookie_secret],
|
||||
secure: context.server.cookie_secure,
|
||||
},
|
||||
});
|
||||
|
||||
export function getSession(cookie: string | null) {
|
||||
return sessionStorage.getSession(cookie);
|
||||
}
|
||||
|
||||
export type ServerSession = Session<SessionData, SessionFlashData>;
|
||||
export async function auth(request: Request) {
|
||||
const cookie = request.headers.get('Cookie');
|
||||
const session = await sessionStorage.getSession(cookie);
|
||||
if (!session.has('hsApiKey')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
export function destroySession(session: Session) {
|
||||
return sessionStorage.destroySession(session);
|
||||
}
|
||||
|
||||
export function commitSession(session: Session, opts?: { maxAge?: number }) {
|
||||
return sessionStorage.commitSession(session, opts);
|
||||
}
|
||||
@ -1,32 +0,0 @@
|
||||
import { useEffect, useMemo, useRef } from 'react';
|
||||
import { useFetcher } from 'react-router';
|
||||
import { HostInfo } from '~/types';
|
||||
|
||||
export default function useAgent(nodeIds: string[], interval = 3000) {
|
||||
const fetcher = useFetcher<Record<string, HostInfo>>();
|
||||
const qp = useMemo(
|
||||
() => new URLSearchParams({ node_ids: nodeIds.join(',') }),
|
||||
[nodeIds],
|
||||
);
|
||||
|
||||
const idRef = useRef<string[]>([]);
|
||||
useEffect(() => {
|
||||
if (idRef.current.join(',') !== nodeIds.join(',')) {
|
||||
fetcher.load(`/api/agent?${qp.toString()}`);
|
||||
idRef.current = nodeIds;
|
||||
}
|
||||
|
||||
const intervalID = setInterval(() => {
|
||||
fetcher.load(`/api/agent?${qp.toString()}`);
|
||||
}, interval);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalID);
|
||||
};
|
||||
}, [interval, qp]);
|
||||
|
||||
return {
|
||||
data: fetcher.data,
|
||||
isLoading: fetcher.state === 'loading',
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user