mirror of
https://github.com/tale/headplane.git
synced 2026-07-27 08:08:56 +00:00
Compare commits
70 Commits
v0.6.1
...
v0.6.2-beta.1
| Author | SHA1 | Date | |
|---|---|---|---|
| e09c5760af | |||
| a824ea3dcf | |||
| bf3fdd47cd | |||
| f6cdad535a | |||
| ba61656fb0 | |||
| d363ed5486 | |||
| 45de5f6833 | |||
| afc5d5451a | |||
| ef6563742c | |||
| 86184e3420 | |||
| d3d7c7cc0e | |||
| c0c4ecf631 | |||
| 5ea96db816 | |||
| cc533f1b72 | |||
| 3d9b6370bf | |||
| afd512138b | |||
| 08a251cc8c | |||
| 63d1e84ebe | |||
| 20f5342ea3 | |||
| db850942ef | |||
| c9986ba466 | |||
| d331a22951 | |||
| 6be801e9bb | |||
| 299859e279 | |||
| 5090733013 | |||
| 7d77f2cb81 | |||
| c3e07e4e10 | |||
| 1aa921aebe | |||
| 444b2325fb | |||
| c84e9ca4a8 | |||
| d68737e410 | |||
| a68aedc297 | |||
| 1c0561edb8 | |||
| e883a7cd63 | |||
| cc2112a805 | |||
| afde3399e1 | |||
| b1c93512df | |||
| ce4be73faf | |||
| 192cce23b0 | |||
| 7d7e08ee54 | |||
| 06a667b561 | |||
| 75c688fd00 | |||
| 28ad1d439f | |||
| 135d2940f1 | |||
| 497a782f17 | |||
| 6d5e44f585 | |||
| 94e5cb95c6 | |||
| 800d73248d | |||
| 66b6dea068 | |||
| 489b12460f | |||
| ef8dcec9c8 | |||
| f66f778398 | |||
| 6eabfb7610 | |||
| a68df033a2 | |||
| 53b8f76ac6 | |||
| df4d864838 | |||
| c9bae42622 | |||
| a2a28e747b | |||
| d175171efc | |||
| ac9ce72078 | |||
| f44881b399 | |||
| 3b93f2fb61 | |||
| 1b2d7e305a | |||
| 639e7e9c6b | |||
| 5d94667186 | |||
| 225dfceddb | |||
| cd379cd10d | |||
| 8f260b79f1 | |||
| eaffcd7b62 | |||
| 4ccc73d7e4 |
+8
-4
@@ -1,7 +1,11 @@
|
||||
node_modules
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
|
||||
/.cache
|
||||
/.github
|
||||
/.react-router
|
||||
/node_modules
|
||||
/build
|
||||
/docs
|
||||
/nix
|
||||
|
||||
.dockerignore
|
||||
.env
|
||||
Dockerfile
|
||||
|
||||
@@ -2,14 +2,12 @@ name: Build
|
||||
on:
|
||||
push:
|
||||
paths-ignore:
|
||||
- ".zed/**"
|
||||
- "assets/**"
|
||||
- "docs/**"
|
||||
- "CHANGELOG.md"
|
||||
- "README.md"
|
||||
branches:
|
||||
- "main"
|
||||
- "next"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
@@ -21,41 +19,37 @@ permissions:
|
||||
contents: read # Read access to the repository
|
||||
|
||||
jobs:
|
||||
native:
|
||||
name: native
|
||||
ci:
|
||||
name: Build and Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Mise
|
||||
uses: jdx/mise-action@v2
|
||||
|
||||
- name: Set caching paths
|
||||
shell: bash
|
||||
run: |
|
||||
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
|
||||
echo "GO_CACHE=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||
echo "GO_MODCACHE=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
|
||||
- uses: actions/cache@v4
|
||||
name: Setup pnpm cache
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
path: ${{ env.STORE_PATH }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
run_install: false
|
||||
|
||||
- name: Setup Go cache
|
||||
uses: actions/cache@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
path: |
|
||||
${{ env.GO_CACHE }}
|
||||
${{ env.GO_MODCACHE }}
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod', '**/go.sum') }}
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: CI pipeline
|
||||
run: mise run ci
|
||||
run: ./build.sh --skip-pnpm-prune
|
||||
|
||||
- name: Run unit tests
|
||||
run: pnpm run test:unit
|
||||
|
||||
- name: Run integration tests
|
||||
run: pnpm run test:integration
|
||||
|
||||
nix:
|
||||
name: nix
|
||||
|
||||
+6
-3
@@ -4,9 +4,12 @@ node_modules
|
||||
/build
|
||||
/test
|
||||
.env
|
||||
app/hp_ssh.wasm
|
||||
app/wasm_exec.js
|
||||
/app/hp_ssh.wasm
|
||||
/app/wasm_exec.js
|
||||
|
||||
/public/hp_ssh.wasm
|
||||
/public/wasm_exec.js
|
||||
|
||||
/docs/.vitepress/dist/
|
||||
/docs/.vitepress/cache/
|
||||
|
||||
/.direnv
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"formatter": {
|
||||
"language_server": {
|
||||
"name": "biome"
|
||||
}
|
||||
},
|
||||
"code_actions_on_format": {
|
||||
"source.fixAll.biome": true,
|
||||
"source.organizeImports.biome": true,
|
||||
"source.organizeImports": true
|
||||
},
|
||||
"languages": {
|
||||
"YAML": {
|
||||
"tab_size": 2,
|
||||
"hard_tabs": false
|
||||
},
|
||||
"Go": {
|
||||
"formatter": {
|
||||
"language_server": {
|
||||
"name": "gopls"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+130
-38
@@ -1,4 +1,22 @@
|
||||
### 0.6.1 (October 12, 2025)
|
||||
# 0.6.2 (Next)
|
||||
- Added support for Headscale 0.27.0 and 0.27.1
|
||||
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
|
||||
- Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)).
|
||||
- Implemented the ability to customize the build with a custom script (see `./build.sh --help` for more information).
|
||||
- Attempt to warn against misconfigured cookie settings on the login page.
|
||||
- Made `server.cookie_max_age` and `server.cookie_domain` configurable (closes [#348](https://github.com/tale/headplane/issues/348)).
|
||||
- Re-worked the configuration loading system with several enhancements:
|
||||
- It is now possible to skip a configuration file and only use environment variables (closes [#150](https://github.com/tale/headplane/issues/150)).
|
||||
- Secret path loading has been reworked from the ground up to be more reliable (closes [#334](https://github.com/tale/headplane/issues/334)).
|
||||
- Added better testing and validation for configuration loading
|
||||
- Re-worked the OIDC integration to adhere to the correct standards and surface more errors to the user.
|
||||
- Removed several unnecessarily verbose or spammy log messages.
|
||||
- Updated the minimum Docker API used to support the latest Docker versions (via [#370](https://github.com/tale/headplane/pull/370)).
|
||||
- Enhanced the node tag dialog to show a dropdown of assignable tags (via [#362](https://github.com/tale/headplane/pull/362)).
|
||||
- Fixed an issue where the website favicon would not load correctly (closes [#323](https://github.com/tale/headplane/issues/323)).
|
||||
---
|
||||
|
||||
# 0.6.1 (October 12, 2025)
|
||||
- **Headplane now supports connecting to machines via SSH in the web browser.**
|
||||
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
|
||||
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
|
||||
@@ -27,7 +45,9 @@
|
||||
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
|
||||
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
|
||||
|
||||
### 0.6.0 (May 25, 2025)
|
||||
---
|
||||
|
||||
# 0.6.0 (May 25, 2025)
|
||||
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
|
||||
- Breaking API changes with routes and pre auth keys are now supported (closes [#204](https://github.com/tale/headplane/issues/204)).
|
||||
- Older versions of Headscale will not work with Headplane.
|
||||
@@ -52,15 +72,21 @@
|
||||
- Tighten `proc` integration logic by checking for the `headscale serve` command (via #[195](https://github.com/tale/headplane/pull/195)).
|
||||
- Strip newlines in the OIDC `client_secret_path` file if provided (closes [#199](https://github.com/tale/headplane/issues/199)).
|
||||
|
||||
### 0.5.10 (April 4, 2025)
|
||||
---
|
||||
|
||||
# 0.5.10 (April 4, 2025)
|
||||
- Fix an issue where other preferences to skip onboarding affected every user.
|
||||
|
||||
### 0.5.9 (April 3, 2025)
|
||||
---
|
||||
|
||||
# 0.5.9 (April 3, 2025)
|
||||
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
|
||||
- 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)
|
||||
---
|
||||
|
||||
# 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.
|
||||
@@ -68,10 +94,14 @@
|
||||
- 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)
|
||||
---
|
||||
|
||||
# 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)
|
||||
---
|
||||
|
||||
# 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.
|
||||
@@ -91,10 +121,14 @@ If there are no users, the first user will be given an `owner` role which cannot
|
||||
- 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)
|
||||
---
|
||||
|
||||
# 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)
|
||||
---
|
||||
|
||||
# 0.5.4 (March 18, 2025)
|
||||
- Fixed a typo in the Kubernetes documentation
|
||||
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
|
||||
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
|
||||
@@ -108,20 +142,28 @@ If there are no users, the first user will be given an `owner` role which cannot
|
||||
- You can now pass in an OIDC client secret through `oidc.client_secret_path` in the config (fixes [#126](https://github.com/tale/headplane/issues/126))
|
||||
- Correctly handle differently localized number inputs (fixes [#125](https://github.com/tale/headplane/issues/125))
|
||||
|
||||
### 0.5.3 (March 1, 2025)
|
||||
---
|
||||
|
||||
# 0.5.3 (March 1, 2025)
|
||||
- Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111))
|
||||
- Added an ARIA indicator for when an input is required and fixed the confirm buttons (fixed [#116](https://github.com/tale/headplane/issues/116))
|
||||
- Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112))
|
||||
|
||||
### 0.5.2 (February 28, 2025)
|
||||
---
|
||||
|
||||
# 0.5.2 (February 28, 2025)
|
||||
- Hotfixed an issue where the server bundle got reloaded on each request
|
||||
|
||||
### 0.5.1 (February 28, 2025)
|
||||
---
|
||||
|
||||
# 0.5.1 (February 28, 2025)
|
||||
- Fixed an issue that caused the entire server to crash on start
|
||||
- Fixed the published semver tags from Docker
|
||||
- Fixed the Kubernetes integration not reading the config
|
||||
|
||||
### 0.5 (February 27, 2025)
|
||||
---
|
||||
|
||||
# 0.5 (February 27, 2025)
|
||||
> This release is a major overhaul and contains a significant breaking change.
|
||||
> We now use a config file for all settings instead of environment variables.
|
||||
> Please see [config.example.yaml](/config.example.yaml) for the new format.
|
||||
@@ -136,10 +178,14 @@ If there are no users, the first user will be given an `owner` role which cannot
|
||||
- Settings that were previously shared like `public_url` or `oidc` are now separate within Headplane/Headscale. This is a rather large breaking change but fixes cases where a user may choose to utilize Headscale OIDC for Tailscale but not for the Headplane UI.
|
||||
- Deprecate the `latest` tag in Docker for explicit versioning and `edge` for nightly builds.
|
||||
|
||||
### 0.4.1 (January 18, 2025)
|
||||
---
|
||||
|
||||
# 0.4.1 (January 18, 2025)
|
||||
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
|
||||
|
||||
### 0.4.0 (January 18, 2025)
|
||||
---
|
||||
|
||||
# 0.4.0 (January 18, 2025)
|
||||
- Switched from Remix.run to React-Router
|
||||
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
|
||||
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
|
||||
@@ -152,29 +198,41 @@ If there are no users, the first user will be given an `owner` role which cannot
|
||||
- Reusable Pre-Auth Keys no longer show expired when used (PR [#88](https://github.com/tale/headplane/pull/88))
|
||||
- Tweaked some CSS issues in the UI
|
||||
|
||||
### 0.3.9 (December 6, 2024)
|
||||
---
|
||||
|
||||
# 0.3.9 (December 6, 2024)
|
||||
- Fixed a race condition bug in the OIDC validation code
|
||||
|
||||
### 0.3.8 (December 6, 2024)
|
||||
---
|
||||
|
||||
# 0.3.8 (December 6, 2024)
|
||||
- Added a little HTML footer to show the login page and link to a donation page.
|
||||
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
|
||||
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
|
||||
|
||||
### 0.3.7 (November 30, 2024)
|
||||
---
|
||||
|
||||
# 0.3.7 (November 30, 2024)
|
||||
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
|
||||
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
|
||||
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
|
||||
- Fixed an issue where the copy machine registration command had a typo.
|
||||
|
||||
### 0.3.6 (November 20, 2024)
|
||||
---
|
||||
|
||||
# 0.3.6 (November 20, 2024)
|
||||
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
|
||||
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
|
||||
|
||||
### 0.3.5 (November 8, 2024)
|
||||
---
|
||||
|
||||
# 0.3.5 (November 8, 2024)
|
||||
- Quickfix a bug where environment variables are ignored on the server.
|
||||
- Remove a nagging error about missing cookie since that happens when signed out.
|
||||
|
||||
### 0.3.4 (November 7, 2024)
|
||||
---
|
||||
|
||||
# 0.3.4 (November 7, 2024)
|
||||
- Clicking on the machine name in the users page now takes you to the machine overview page.
|
||||
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
|
||||
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
|
||||
@@ -184,70 +242,94 @@ If there are no users, the first user will be given an `owner` role which cannot
|
||||
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
|
||||
- Added a basic check to see if the API keys passed via cookies are invalid.
|
||||
|
||||
### 0.3.3 (October 28, 2024)
|
||||
---
|
||||
|
||||
# 0.3.3 (October 28, 2024)
|
||||
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
|
||||
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
|
||||
- Improved ACL editor performance by switching back to CodeMirror 6
|
||||
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
|
||||
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
|
||||
|
||||
### 0.3.2 (October 11, 2024)
|
||||
---
|
||||
|
||||
# 0.3.2 (October 11, 2024)
|
||||
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
|
||||
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
|
||||
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
|
||||
- Handle `null` values on machine expiry when using a database like PostgreSQL.
|
||||
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
|
||||
|
||||
### 0.3.1 (October 3, 2024)
|
||||
---
|
||||
|
||||
# 0.3.1 (October 3, 2024)
|
||||
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
|
||||
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
|
||||
- Added the option for debug logs with the `DEBUG=true` environment variable.
|
||||
|
||||
### 0.3.0 (September 25, 2024)
|
||||
---
|
||||
|
||||
# 0.3.0 (September 25, 2024)
|
||||
- Bumped the minimum supported version of Headscale to 0.23.
|
||||
- Updated the UI to respect `dns.use_username_in_magic_dns`.
|
||||
|
||||
### 0.2.4 (August 24, 2024)
|
||||
---
|
||||
|
||||
# 0.2.4 (August 24, 2024)
|
||||
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
|
||||
- Removed the `ACL_FILE` environment variable since it's no longer needed.
|
||||
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
|
||||
- Fixed a bug where removing Split DNS configurations would crash the UI.
|
||||
|
||||
### 0.2.3 (August 23, 2024)
|
||||
---
|
||||
|
||||
# 0.2.3 (August 23, 2024)
|
||||
- Change the minimum required version of Headscale to 0.23-beta2
|
||||
- Support the new API policy mode for Headscale 0.23-beta1
|
||||
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
|
||||
- If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24))
|
||||
|
||||
### 0.2.2 (August 2, 2024)
|
||||
---
|
||||
|
||||
# 0.2.2 (August 2, 2024)
|
||||
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
|
||||
- Added a new logger utility that shows categories, levels, and timestamps.
|
||||
- Reimplemented the integration system to be more resilient and log more information.
|
||||
- Fixed an issue where the /proc integration found `undefined` PIDs.
|
||||
|
||||
### 0.2.1 (July 7, 2024)
|
||||
---
|
||||
|
||||
# 0.2.1 (July 7, 2024)
|
||||
- Added the ability to manage custom DNS records on your Tailnet.
|
||||
- ACL tags for machines are now able to be changed via the machine menu.
|
||||
- Fixed a bug where the ACL editor did not show the diffs correctly.
|
||||
- Fixed an issue that stopped the "Discard changes" button in the ACL editor from working.
|
||||
|
||||
### 0.2.0 (June 23, 2024)
|
||||
---
|
||||
|
||||
# 0.2.0 (June 23, 2024)
|
||||
- Fix the dropdown options for machines not working on the machines page.
|
||||
- Add an option to change the machine owner in the dropdown (aside from the users page).
|
||||
|
||||
### 0.1.9 (June 2, 2024)
|
||||
---
|
||||
|
||||
# 0.1.9 (June 2, 2024)
|
||||
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
|
||||
- Utilize magic DNS hostnames for the machine overview page.
|
||||
- Fixed the expiry issue once and for all.
|
||||
- Add a nightly build with the `ghcr.io/tale/headplane:edge` tag
|
||||
|
||||
### 0.1.8 (June 2, 2024)
|
||||
---
|
||||
|
||||
# 0.1.8 (June 2, 2024)
|
||||
- Built basic functionality for the machine overview page (by machine ID).
|
||||
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
|
||||
- Prevent users from being deleted if they still have ownership of machines.
|
||||
- Fixed some type issues where `Date` was being used instead of `string` for timestamps.
|
||||
|
||||
### 0.1.7 (May 30, 2024)
|
||||
---
|
||||
|
||||
# 0.1.7 (May 30, 2024)
|
||||
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
|
||||
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
|
||||
- Made the strict configuration loader more lenient to allow for more flexibility.
|
||||
@@ -255,19 +337,25 @@ If there are no users, the first user will be given an `owner` role which cannot
|
||||
- Headplane's context now only loads once at start instead of being lazy-loaded.
|
||||
- Improved logging and error propagation so that it's easier to debug issues.
|
||||
|
||||
### 0.1.6 (May 22, 2024)
|
||||
---
|
||||
|
||||
# 0.1.6 (May 22, 2024)
|
||||
- Added experimental support for advanced integration without Docker.
|
||||
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
|
||||
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
|
||||
|
||||
### 0.1.5 (May 20, 2024)
|
||||
---
|
||||
|
||||
# 0.1.5 (May 20, 2024)
|
||||
- Robust configuration handling with fallbacks based on the headscale source.
|
||||
- Support for `client_secret_path` on configuration file based OIDC.
|
||||
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
|
||||
- `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works).
|
||||
- Fixed button responders not actually being invoked (should fix the ACL page).
|
||||
|
||||
### 0.1.4 (May 15, 2024)
|
||||
---
|
||||
|
||||
# 0.1.4 (May 15, 2024)
|
||||
|
||||
- Users can now be created, renamed, and deleted on the users page.
|
||||
- Machines can be dragged between users to change their ownership.
|
||||
@@ -276,13 +364,17 @@ If there are no users, the first user will be given an `owner` role which cannot
|
||||
- Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues).
|
||||
- Upgrade other dependencies
|
||||
|
||||
### 0.1.3 (May 4, 2024)
|
||||
---
|
||||
|
||||
# 0.1.3 (May 4, 2024)
|
||||
|
||||
- Switched to a better icon set for the UI.
|
||||
- Support stable scrollbar gutter if supported by the browser.
|
||||
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
|
||||
|
||||
### 0.1.2 (May 1, 2024)
|
||||
---
|
||||
|
||||
# 0.1.2 (May 1, 2024)
|
||||
|
||||
- Added support for renaming, expiring, removing, and managing the routes of a machine.
|
||||
- Implemented an expiry check for machines which now reflect on the machine table.
|
||||
|
||||
+41
-38
@@ -1,11 +1,7 @@
|
||||
FROM --platform=$BUILDPLATFORM jdxcode/mise:latest AS mise-context
|
||||
COPY mise.toml ./
|
||||
RUN --mount=type=secret,id=gh_token,env=MISE_GITHUB_TOKEN mise install
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
|
||||
WORKDIR /run
|
||||
|
||||
FROM --platform=$BUILDPLATFORM mise-context AS go-build
|
||||
WORKDIR /build/
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
COPY go.mod go.sum build.sh ./
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd/ ./cmd/
|
||||
@@ -14,50 +10,57 @@ COPY internal/ ./internal/
|
||||
ARG TARGETOS
|
||||
ARG TARGETARCH
|
||||
ARG IMAGE_TAG
|
||||
RUN mkdir -p /build/app/ && \
|
||||
GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 IMAGE_TAG=$IMAGE_TAG \
|
||||
mise run wasm ::: agent ::: fake-shell
|
||||
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 IMAGE_TAG=$IMAGE_TAG \
|
||||
./build.sh --wasm --agent --fake-shell \
|
||||
--wasm-output /bin/hp_ssh.wasm \
|
||||
--agent-output /bin/hp_agent \
|
||||
--fake-shell-output /bin/fake-sh
|
||||
|
||||
RUN chmod +x /build/build/hp_agent
|
||||
RUN chmod +x /build/build/sh
|
||||
RUN chmod +x /bin/hp_ssh.wasm
|
||||
RUN chmod +x /bin/hp_agent
|
||||
RUN chmod +x /bin/fake-sh
|
||||
|
||||
FROM --platform=$BUILDPLATFORM mise-context AS js-build
|
||||
WORKDIR /build
|
||||
COPY patches ./patches
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN mise trust
|
||||
COPY --from=go-build /build/app/hp_ssh.wasm /build/app/hp_ssh.wasm
|
||||
COPY --from=go-build /build/app/wasm_exec.js /build/app/wasm_exec.js
|
||||
|
||||
ARG IMAGE_TAG
|
||||
RUN IMAGE_TAG=$IMAGE_TAG pnpm run build
|
||||
# Folder needs to exist for later stages
|
||||
RUN mkdir -p /var/lib/headplane/agent
|
||||
|
||||
FROM --platform=$BUILDPLATFORM node:22.16-slim AS js-base
|
||||
WORKDIR /run
|
||||
|
||||
RUN corepack enable
|
||||
COPY patches ./patches
|
||||
COPY package.json pnpm-lock.yaml build.sh ./
|
||||
|
||||
COPY --from=go-base /bin/hp_ssh.wasm /run/app/hp_ssh.wasm
|
||||
COPY --from=go-base /bin/wasm_exec.js /run/app/wasm_exec.js
|
||||
RUN ./build.sh --app --app-install-only
|
||||
|
||||
COPY . .
|
||||
RUN ./build.sh --app
|
||||
|
||||
FROM gcr.io/distroless/nodejs22-debian12:latest AS final
|
||||
COPY --from=js-build /build/build/ /app/build/
|
||||
COPY --from=js-build /build/drizzle /app/drizzle/
|
||||
COPY --from=js-build /var/lib/headplane /var/lib/headplane
|
||||
COPY --from=js-build /build/node_modules/ /app/node_modules/
|
||||
COPY --from=go-build /build/build/hp_agent /usr/libexec/headplane/agent
|
||||
COPY --from=js-base /run/build /app/build
|
||||
COPY --from=js-base /run/drizzle /app/drizzle
|
||||
COPY --from=js-base /run/node_modules /app/node_modules
|
||||
|
||||
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
|
||||
COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
||||
|
||||
# Fake shell to inform the user that they should use the debug image
|
||||
COPY --from=go-build /build/build/sh /bin/sh
|
||||
COPY --from=go-build /build/build/sh /bin/bash
|
||||
COPY --from=go-base /bin/fake-sh /bin/sh
|
||||
COPY --from=go-base /bin/fake-sh /bin/bash
|
||||
|
||||
WORKDIR /app
|
||||
CMD [ "/app/build/server/index.js" ]
|
||||
|
||||
FROM node:22-alpine AS debug-shell
|
||||
RUN apk add --no-cache bash curl git
|
||||
RUN apk add --no-cache bash curl
|
||||
|
||||
COPY --from=js-build /build/build/ /app/build/
|
||||
COPY --from=js-build /build/drizzle /app/drizzle/
|
||||
COPY --from=js-build /var/lib/headplane /var/lib/headplane
|
||||
COPY --from=js-build /build/node_modules/ /app/node_modules/
|
||||
COPY --from=go-build /build/build/hp_agent /usr/libexec/headplane/agent
|
||||
COPY --from=js-base /run/build /app/build
|
||||
COPY --from=js-base /run/drizzle /app/drizzle
|
||||
COPY --from=js-base /run/node_modules /app/node_modules
|
||||
|
||||
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
|
||||
COPY --from=go-base /var/lib/headplane /var/lib/headplane
|
||||
|
||||
WORKDIR /app
|
||||
CMD [ "node", "/app/build/server/index.js" ]
|
||||
|
||||
@@ -32,17 +32,7 @@ These are some of the features that Headplane offers:
|
||||
- Configurability for Headscale's settings
|
||||
|
||||
## Deployment
|
||||
Headplane runs as a server-based web-application, meaning you'll need a server to run it.
|
||||
It's available as a Docker image (recommended) or through a manual installation.
|
||||
There are 2 ways to deploy Headplane:
|
||||
|
||||
- ### [Integrated Mode (Recommended)](/docs/Integrated-Mode.md)
|
||||
Integrated mode unlocks all the features of Headplane and is the most
|
||||
feature-complete deployment method. It communicates with Headscale directly.
|
||||
|
||||
- ### [Simple Mode](/docs/Simple-Mode.md)
|
||||
Simple mode does not include the automatic management of DNS and Headplane
|
||||
settings, requiring manual editing and reloading when making changes.
|
||||
Refer to the [website](https://headplane.net) for detailed installation instructions.
|
||||
|
||||
## Versioning
|
||||
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
FROM golang:1.23 AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY agent/ ./agent
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build \
|
||||
-trimpath \
|
||||
-ldflags "-s -w" \
|
||||
-o /app/hp_agent ./agent/cmd/hp_agent
|
||||
|
||||
FROM scratch
|
||||
COPY --from=builder /app/hp_agent /hp_agent
|
||||
ENTRYPOINT ["/hp_agent"]
|
||||
@@ -5,7 +5,7 @@ import toast from '~/utils/toast';
|
||||
|
||||
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
|
||||
isCopyable?: boolean;
|
||||
children: string | string[];
|
||||
children: string | string[] | number;
|
||||
}
|
||||
|
||||
export default function Code({ isCopyable, children, className }: CodeProps) {
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { isRouteErrorResponse, useRouteError } from 'react-router';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import cn from '~/utils/cn';
|
||||
import Card from './Card';
|
||||
import Code from './Code';
|
||||
|
||||
interface Props {
|
||||
type?: 'full' | 'embedded';
|
||||
}
|
||||
|
||||
export function getErrorMessage(error: Error | unknown): {
|
||||
title: string;
|
||||
message: string;
|
||||
} {
|
||||
if (error instanceof ResponseError) {
|
||||
if (error.responseObject?.message) {
|
||||
return {
|
||||
title: 'Headscale Error',
|
||||
message: String(error.responseObject.message),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Headscale Error',
|
||||
message: error.response,
|
||||
};
|
||||
}
|
||||
|
||||
if (!(error instanceof Error)) {
|
||||
return {
|
||||
title: 'Unknown Error',
|
||||
message: String(error),
|
||||
};
|
||||
}
|
||||
|
||||
let rootError = error;
|
||||
|
||||
// Traverse the error chain to find the root cause
|
||||
if (error.cause) {
|
||||
rootError = error.cause as Error;
|
||||
while (rootError.cause) {
|
||||
rootError = rootError.cause as Error;
|
||||
}
|
||||
}
|
||||
|
||||
// If we are aggregate, concat into a single message
|
||||
if (rootError instanceof AggregateError) {
|
||||
throw new Error('Unhandled AggregateError');
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Error',
|
||||
message: rootError.message,
|
||||
};
|
||||
}
|
||||
|
||||
export function ErrorPopup({ type = 'full' }: Props) {
|
||||
const error = useRouteError();
|
||||
const routing = isRouteErrorResponse(error);
|
||||
const { title, message } = getErrorMessage(error);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center',
|
||||
type === 'embedded'
|
||||
? 'pointer-events-none mt-24'
|
||||
: 'fixed inset-0 h-screen w-screen z-50',
|
||||
)}
|
||||
>
|
||||
<Card>
|
||||
<div className="flex items-center gap-4">
|
||||
<AlertCircle className="w-8 h-8 text-red-500" />
|
||||
<div className="flex justify-between items-center gap-2 w-full">
|
||||
<Card.Title className="text-3xl mb-0">{title}</Card.Title>
|
||||
{routing && <Code className="text-2xl">{`${error.status}`}</Code>}
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-4 text-headplane-100 dark:text-headplane-800" />
|
||||
<Card.Text
|
||||
className={cn('py-4 text-lg', routing ? 'font-normal' : 'font-mono')}
|
||||
>
|
||||
{routing ? error.data : message}
|
||||
</Card.Text>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+14
-14
@@ -50,11 +50,11 @@ function Select(props: SelectProps) {
|
||||
<div className={cn('flex flex-col', props.className)}>
|
||||
<label
|
||||
{...labelProps}
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
'text-xs font-medium px-3 mb-0.5',
|
||||
'text-headplane-700 dark:text-headplane-100',
|
||||
)}
|
||||
htmlFor={id}
|
||||
>
|
||||
{props.label}
|
||||
</label>
|
||||
@@ -63,18 +63,18 @@ function Select(props: SelectProps) {
|
||||
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
|
||||
'bg-white dark:bg-headplane-900',
|
||||
'border border-headplane-100 dark:border-headplane-800',
|
||||
props.isInvalid && 'ring-red-400',
|
||||
)}
|
||||
>
|
||||
<input
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
id={id}
|
||||
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
|
||||
data-1p-ignore
|
||||
id={id}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<button
|
||||
{...buttonProps}
|
||||
ref={buttonRef}
|
||||
className={cn(
|
||||
'flex items-center justify-center p-1 rounded-lg m-1',
|
||||
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
|
||||
@@ -82,6 +82,7 @@ function Select(props: SelectProps) {
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
|
||||
)}
|
||||
ref={buttonRef}
|
||||
>
|
||||
<ChevronDown className="p-0.5" />
|
||||
</button>
|
||||
@@ -99,12 +100,12 @@ function Select(props: SelectProps) {
|
||||
)}
|
||||
{state.isOpen && (
|
||||
<Popover
|
||||
popoverRef={popoverRef}
|
||||
triggerRef={inputRef}
|
||||
state={state}
|
||||
className="w-full max-w-xs"
|
||||
isNonModal
|
||||
placement="bottom start"
|
||||
className="w-full max-w-xs"
|
||||
popoverRef={popoverRef}
|
||||
state={state}
|
||||
triggerRef={inputRef}
|
||||
>
|
||||
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
|
||||
</Popover>
|
||||
@@ -114,23 +115,22 @@ function Select(props: SelectProps) {
|
||||
}
|
||||
|
||||
interface ListBoxProps extends AriaListBoxOptions<object> {
|
||||
listBoxRef?: React.RefObject<HTMLUListElement | null>;
|
||||
listBoxRef: React.RefObject<HTMLUListElement | null>;
|
||||
state: ListState<object>;
|
||||
}
|
||||
|
||||
function ListBox(props: ListBoxProps) {
|
||||
const { listBoxRef, state } = props;
|
||||
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null);
|
||||
const { listBoxProps } = useListBox(props, state, ref);
|
||||
const { listBoxProps } = useListBox(props, state, listBoxRef);
|
||||
|
||||
return (
|
||||
<ul
|
||||
{...listBoxProps}
|
||||
ref={listBoxRef}
|
||||
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
|
||||
ref={listBoxRef}
|
||||
>
|
||||
{[...state.collection].map((item) => (
|
||||
<Option key={item.key} item={item} state={state} />
|
||||
<Option item={item} key={item.key} state={state} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
@@ -154,7 +154,6 @@ function Option({ item, state }: OptionProps) {
|
||||
return (
|
||||
<li
|
||||
{...optionProps}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex items-center justify-between',
|
||||
'py-2 px-3 mx-1 rounded-lg mb-1',
|
||||
@@ -164,6 +163,7 @@ function Option({ item, state }: OptionProps) {
|
||||
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
|
||||
isDisabled && 'text-headplane-300 dark:text-headplane-600',
|
||||
)}
|
||||
ref={ref}
|
||||
>
|
||||
{item.rendered}
|
||||
{isSelected && <Check className="p-0.5" />}
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { isRouteErrorResponse } from 'react-router';
|
||||
import {
|
||||
isApiError,
|
||||
isConnectionError,
|
||||
} from '~/server/headscale/api/error-client';
|
||||
import cn from '~/utils/cn';
|
||||
import Card from './Card';
|
||||
import Code from './Code';
|
||||
import Link from './Link';
|
||||
|
||||
export function getErrorMessage(error: Error | unknown): {
|
||||
title: string;
|
||||
jsxMessage: React.ReactNode;
|
||||
} {
|
||||
if (isRouteErrorResponse(error)) {
|
||||
if (isApiError(error.data)) {
|
||||
const { statusCode, rawData, data, requestUrl } = error.data;
|
||||
if (statusCode >= 500) {
|
||||
return {
|
||||
title: 'Cannot connect to Headscale API',
|
||||
jsxMessage: (
|
||||
<Card.Text>
|
||||
There was an error communicating with the Headscale API.
|
||||
<br />
|
||||
The server responded with a status code of{' '}
|
||||
<strong>{statusCode}</strong>, indicating a server-side issue.
|
||||
Please check the Headscale server status and try again later.
|
||||
</Card.Text>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const authError =
|
||||
error.data.statusCode === 401 || error.data.statusCode === 403;
|
||||
|
||||
return {
|
||||
title: 'Invalid response from Headscale API',
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text className="leading-snug">
|
||||
The Headscale API returned an unexpected response.
|
||||
{authError ? (
|
||||
<>
|
||||
{' '}
|
||||
The status code indicates an authentication error. Please
|
||||
verify your API key and Headplane configuration.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{' '}
|
||||
You may be using an unsupported version of Headscale or this
|
||||
may be a bug.
|
||||
</>
|
||||
)}
|
||||
</Card.Text>
|
||||
<ul className="list-disc list-inside mt-2">
|
||||
<li>
|
||||
Request URL: <Code>{requestUrl}</Code>
|
||||
</li>
|
||||
<li>
|
||||
Status Code:{' '}
|
||||
<Code>
|
||||
{/* @ts-expect-error */}
|
||||
{data === null ? (
|
||||
<>
|
||||
{statusCode} {rawData}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{statusCode} {error.statusText}
|
||||
</>
|
||||
)}
|
||||
</Code>
|
||||
</li>
|
||||
</ul>
|
||||
<Card.Text className="text-lg font-semibold mt-4">
|
||||
Error Details
|
||||
</Card.Text>
|
||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||
<code>{JSON.stringify(error.data, null, 2)}</code>
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (isConnectionError(error.data)) {
|
||||
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
|
||||
return {
|
||||
title: 'Cannot connect to Headscale API',
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text className="leading-snug">
|
||||
Headplane was unable to reach the Headscale API. Please check your
|
||||
network setup and configuration to ensure Headplane is able to
|
||||
connect.
|
||||
</Card.Text>
|
||||
<Card.Text className="text-lg font-semibold mt-4">
|
||||
Error Details
|
||||
</Card.Text>
|
||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||
{requestUrl}
|
||||
<br />
|
||||
{errorCode}: {errorMessage}
|
||||
{extraData != null && (
|
||||
<>
|
||||
<br />
|
||||
<br />
|
||||
<code>{JSON.stringify(extraData, null, 2)}</code>
|
||||
</>
|
||||
)}
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: `Error ${error.status}`,
|
||||
jsxMessage: (
|
||||
<>
|
||||
There was an error processing your request.
|
||||
<br />
|
||||
Status Code: <strong>{error.status}</strong>
|
||||
<br />
|
||||
Status Text: <strong>{error.statusText}</strong>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!(error instanceof Error)) {
|
||||
return {
|
||||
title: 'Unexpected Error',
|
||||
jsxMessage: (
|
||||
<>
|
||||
<Card.Text>
|
||||
An unexpected error occurred which is most likely a bug. Please
|
||||
consider reporting filing an issue on the{' '}
|
||||
<Link
|
||||
name="Headplane GitHub"
|
||||
to="https://github.com/tale/headplane/issues"
|
||||
>
|
||||
Headplane GitHub
|
||||
</Link>{' '}
|
||||
repository with the details below.
|
||||
</Card.Text>
|
||||
<Card.Text className="text-lg font-semibold mt-4">
|
||||
Error Details
|
||||
</Card.Text>
|
||||
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
|
||||
<code>{JSON.stringify(error, null, 2)}</code>
|
||||
</pre>
|
||||
</>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
// Traverse the error chain to find the root cause
|
||||
let rootError = error;
|
||||
console.log('error', error.cause != null);
|
||||
if (error.cause != null) {
|
||||
rootError = error.cause as Error;
|
||||
while (rootError.cause != null) {
|
||||
rootError = rootError.cause as Error;
|
||||
console.log('setting rootError', rootError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: If we are aggregate, concat into a single message
|
||||
if (rootError instanceof AggregateError) {
|
||||
throw new Error('AggregateError handling not implemented yet');
|
||||
}
|
||||
|
||||
return {
|
||||
title:
|
||||
rootError.name.length > 0 && rootError.name !== 'Error'
|
||||
? `Error: ${rootError.name}`
|
||||
: 'Error',
|
||||
jsxMessage: rootError.message,
|
||||
};
|
||||
}
|
||||
|
||||
interface ErrorBannerProps {
|
||||
error: unknown;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ErrorBanner({ error, className }: ErrorBannerProps) {
|
||||
const { title, jsxMessage } = getErrorMessage(error);
|
||||
|
||||
return (
|
||||
<Card className={cn('w-screen', className)} variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>{title}</Card.Title>
|
||||
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
||||
</div>
|
||||
{jsxMessage}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,6 @@ import { StrictMode, startTransition } from 'react';
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
import { HydratedRouter } from 'react-router/dom';
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
import('react-scan').then(({ scan }) => {
|
||||
scan({ enabled: true });
|
||||
});
|
||||
}
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
|
||||
@@ -3,7 +3,8 @@ import { createReadableStreamFromReadable } from '@react-router/node';
|
||||
import { isbot } from 'isbot';
|
||||
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
|
||||
import { renderToPipeableStream } from 'react-dom/server';
|
||||
import { AppLoadContext, EntryContext, ServerRouter } from 'react-router';
|
||||
import type { AppLoadContext, EntryContext } from 'react-router';
|
||||
import { ServerRouter } from 'react-router';
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
export default function handleRequest(
|
||||
|
||||
+22
-18
@@ -1,27 +1,27 @@
|
||||
import { type LoaderFunctionArgs, Outlet, redirect } from 'react-router';
|
||||
import { ErrorPopup } from '~/components/Error';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Outlet, redirect } from 'react-router';
|
||||
import { ErrorBanner } from '~/components/error-banner';
|
||||
import { pruneEphemeralNodes } from '~/server/db/pruner';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import { isDataUnauthorizedError } from '~/server/headscale/api/error-client';
|
||||
import log from '~/utils/log';
|
||||
import type { Route } from './+types/dashboard';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
...rest
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
const healthy = await context.client.healthcheck();
|
||||
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
await pruneEphemeralNodes({ context, request, ...rest });
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
|
||||
// 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
|
||||
// MARK: The session should stay valid if Headscale isn't healthy
|
||||
const healthy = await api.isHealthy();
|
||||
if (healthy) {
|
||||
try {
|
||||
await context.client.get('v1/apikey', session.api_key);
|
||||
await api.getApiKeys();
|
||||
await pruneEphemeralNodes({ context, request, ...rest });
|
||||
} catch (error) {
|
||||
if (error instanceof ResponseError) {
|
||||
log.debug('api', 'API Key validation failed %o', error);
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
log.warn(
|
||||
'auth',
|
||||
'Logging out %s due to expired API key',
|
||||
session.user.name,
|
||||
);
|
||||
return redirect('/login', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.destroySession(),
|
||||
@@ -44,6 +44,10 @@ export default function Layout() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ErrorPopup type="embedded" />;
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div className="w-fit mx-auto overscroll-contain my-24">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+12
-21
@@ -1,30 +1,22 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { CircleCheckIcon } from 'lucide-react';
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
Outlet,
|
||||
redirect,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import { Outlet, redirect } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Footer from '~/components/Footer';
|
||||
import Header from '~/components/Header';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { users } from '~/server/db/schema';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import toast from '~/utils/toast';
|
||||
import { Route } from './+types/shell';
|
||||
|
||||
// This loads the bare minimum for the application to function
|
||||
// So we know that if context fails to load then well, oops?
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (
|
||||
context.oidc &&
|
||||
typeof context.oidc === 'object' &&
|
||||
session.user.subject !== 'unknown-non-oauth' &&
|
||||
!request.url.endsWith('/onboarding')
|
||||
) {
|
||||
@@ -39,6 +31,7 @@ export async function loader({
|
||||
}
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const check = await context.sessions.check(request, Capabilities.ui_access);
|
||||
return {
|
||||
config: context.hs.c,
|
||||
@@ -62,7 +55,7 @@ export async function loader({
|
||||
),
|
||||
},
|
||||
onboarding: request.url.endsWith('/onboarding'),
|
||||
healthy: await context.client.healthcheck(),
|
||||
healthy: await api.isHealthy(),
|
||||
};
|
||||
} catch {
|
||||
return redirect('/login', {
|
||||
@@ -73,14 +66,12 @@ export async function loader({
|
||||
}
|
||||
}
|
||||
|
||||
export default function Shell() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
|
||||
export default function Shell({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<Header {...data} />
|
||||
<Header {...loaderData} />
|
||||
{/* Always show the outlet if we are onboarding */}
|
||||
{(data.onboarding ? true : data.uiAccess) ? (
|
||||
{(loaderData.onboarding ? true : loaderData.uiAccess) ? (
|
||||
<Outlet />
|
||||
) : (
|
||||
<Card className="mx-auto w-fit mt-24">
|
||||
@@ -96,13 +87,13 @@ export default function Shell() {
|
||||
className="flex text-md font-mono"
|
||||
onPress={async () => {
|
||||
await navigator.clipboard.writeText(
|
||||
`tailscale up --login-server=${data.url}`,
|
||||
`tailscale up --login-server=${loaderData.url}`,
|
||||
);
|
||||
|
||||
toast('Copied to clipboard');
|
||||
}}
|
||||
>
|
||||
tailscale up --login-server={data.url}
|
||||
tailscale up --login-server={loaderData.url}
|
||||
</Button>
|
||||
<p className="text-xs mt-1 opacity-50 text-center">
|
||||
Click this button to copy the command.
|
||||
@@ -113,7 +104,7 @@ export default function Shell() {
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
<Footer {...data} />
|
||||
<Footer {...loaderData} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"0.26.1": ["0.26.0", "0.26.1"],
|
||||
"0.27.0": ["0.27.0"],
|
||||
"0.27.1": ["0.27.1"]
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
{
|
||||
"0.26.0": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.26.1": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.27.0": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.27.1": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
}
|
||||
}
|
||||
+12
-5
@@ -8,12 +8,14 @@ import {
|
||||
useNavigation,
|
||||
} from 'react-router';
|
||||
import '@fontsource-variable/inter';
|
||||
import { ErrorPopup } from '~/components/Error';
|
||||
import { ExternalScripts } from 'remix-utils/external-scripts';
|
||||
import ProgressBar from '~/components/ProgressBar';
|
||||
import ToastProvider from '~/components/ToastProvider';
|
||||
import stylesheet from '~/tailwind.css?url';
|
||||
import { LiveDataProvider } from '~/utils/live-data';
|
||||
import { useToastQueue } from '~/utils/toast';
|
||||
import type { Route } from './+types/root';
|
||||
import { ErrorBanner } from './components/error-banner';
|
||||
|
||||
export const meta: MetaFunction = () => [
|
||||
{ title: 'Headplane' },
|
||||
@@ -38,24 +40,29 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
<Meta />
|
||||
<Links />
|
||||
<link rel="icon" href="favicon.ico" />
|
||||
<link href="favicon.ico" rel="icon" />
|
||||
</head>
|
||||
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
|
||||
{children}
|
||||
<ToastProvider queue={toastQueue} />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
<ExternalScripts />
|
||||
</body>
|
||||
</html>
|
||||
</LiveDataProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary() {
|
||||
return <ErrorPopup />;
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
return (
|
||||
<div className="w-screen h-screen flex items-center justify-center p-4">
|
||||
<ErrorBanner className="max-w-2xl" error={error} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
|
||||
@@ -1,40 +1,35 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import { data } from 'react-router';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { data400, data403 } from '~/utils/res';
|
||||
import type { Route } from './+types/overview';
|
||||
|
||||
// We only check capabilities here and assume it is writable
|
||||
// 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>) {
|
||||
export async function aclAction({ request, context }: Route.ActionArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_policy,
|
||||
);
|
||||
if (!check) {
|
||||
throw data403('You do not have permission to write to the ACL policy');
|
||||
throw data('You do not have permission to write to the ACL policy', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
// Try to write to the ACL policy via the API or via config file (TODO).
|
||||
const formData = await request.formData();
|
||||
const policyData = formData.get('policy')?.toString();
|
||||
if (!policyData) {
|
||||
throw data400('Missing `policy` in the form data.');
|
||||
throw data('Missing `policy` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
try {
|
||||
const { policy, updatedAt } = await context.client.put<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>('v1/policy', session.api_key, {
|
||||
policy: policyData,
|
||||
});
|
||||
|
||||
const { policy, updatedAt } = await api.setPolicy(policyData);
|
||||
return data({
|
||||
success: true,
|
||||
error: undefined,
|
||||
@@ -50,7 +45,7 @@ export async function aclAction({
|
||||
// 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');
|
||||
throw data('Policy is not writable', { status: 403 });
|
||||
}
|
||||
|
||||
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { LoaderFunctionArgs } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import { data } from 'react-router';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { data403 } from '~/utils/res';
|
||||
import type { Route } from './+types/overview';
|
||||
|
||||
// The logic for deciding policy factors is very complicated because
|
||||
// there are so many factors that need to be accounted for:
|
||||
@@ -11,15 +10,13 @@ import { data403 } from '~/utils/res';
|
||||
// 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>) {
|
||||
export async function aclLoader({ request, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(request, Capabilities.read_policy);
|
||||
if (!check) {
|
||||
throw data403('You do not have permission to read the ACL policy.');
|
||||
throw data('You do not have permission to read the ACL policy.', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const flags = {
|
||||
@@ -30,11 +27,9 @@ export async function aclLoader({
|
||||
};
|
||||
|
||||
// Try to load the ACL policy from the API.
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
try {
|
||||
const { policy, updatedAt } = await context.client.get<{
|
||||
policy: string;
|
||||
updatedAt: string | null;
|
||||
}>('v1/policy', session.api_key);
|
||||
const { policy, updatedAt } = await api.getPolicy();
|
||||
|
||||
// Successfully loaded the policy, mark it as readable
|
||||
// If `updatedAt` is null, it means the policy is in file mode.
|
||||
|
||||
@@ -38,14 +38,14 @@ export function Editor(props: EditorProps) {
|
||||
<ClientOnly fallback={<Fallback acl={props.value} />}>
|
||||
{() => (
|
||||
<CodeMirror
|
||||
value={props.value}
|
||||
editable={!props.isDisabled} // Allow editing unless disabled
|
||||
readOnly={props.isDisabled} // Use readOnly if disabled
|
||||
height="100%"
|
||||
extensions={[shopify.jsonc()]}
|
||||
editable={!props.isDisabled}
|
||||
extensions={[shopify.jsonc()]} // Allow editing unless disabled
|
||||
height="100%" // Use readOnly if disabled
|
||||
onChange={(value) => props.onChange(value)}
|
||||
readOnly={props.isDisabled}
|
||||
style={{ height: '100%' }}
|
||||
theme={light ? xcodeLight : xcodeDark}
|
||||
onChange={(value) => props.onChange(value)}
|
||||
value={props.value}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
@@ -92,14 +92,14 @@ export function Differ(props: DifferProps) {
|
||||
{() => (
|
||||
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
|
||||
<Merge.Original
|
||||
extensions={[shopify.jsonc()]}
|
||||
readOnly
|
||||
value={props.left}
|
||||
extensions={[shopify.jsonc()]}
|
||||
/>
|
||||
<Merge.Modified
|
||||
extensions={[shopify.jsonc()]}
|
||||
readOnly
|
||||
value={props.right}
|
||||
extensions={[shopify.jsonc()]}
|
||||
/>
|
||||
</Merge>
|
||||
)}
|
||||
|
||||
@@ -23,12 +23,12 @@ export default function Fallback({ acl }: Props) {
|
||||
/>
|
||||
</div>
|
||||
<textarea
|
||||
readOnly
|
||||
className={cn(
|
||||
'w-full h-editor font-mono resize-none text-sm',
|
||||
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
|
||||
'pl-1 pt-1 leading-snug',
|
||||
)}
|
||||
readOnly
|
||||
value={acl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,34 +1,23 @@
|
||||
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
useFetcher,
|
||||
useLoaderData,
|
||||
useRevalidator,
|
||||
} from 'react-router';
|
||||
import { useFetcher, 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 type { Route } from './+types/overview';
|
||||
import { aclAction } from './acl-action';
|
||||
import { aclLoader } from './acl-loader';
|
||||
import { Differ, Editor } from './components/cm.client';
|
||||
|
||||
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
|
||||
return aclLoader(request);
|
||||
}
|
||||
export const loader = aclLoader;
|
||||
export const action = aclAction;
|
||||
|
||||
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>();
|
||||
export default function Page({
|
||||
loaderData: { access, writable, policy },
|
||||
}: Route.ComponentProps) {
|
||||
const [codePolicy, setCodePolicy] = useState(policy);
|
||||
const fetcher = useFetcher<typeof action>();
|
||||
const { revalidate } = useRevalidator();
|
||||
@@ -75,15 +64,15 @@ export default function Page() {
|
||||
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"
|
||||
to="https://tailscale.com/kb/1018/acls"
|
||||
>
|
||||
Tailscale ACL guide
|
||||
</Link>{' '}
|
||||
and the{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/acls/"
|
||||
name="Headscale ACL documentation"
|
||||
to="https://headscale.net/stable/ref/acls/"
|
||||
>
|
||||
Headscale docs
|
||||
</Link>
|
||||
@@ -91,14 +80,14 @@ export default function Page() {
|
||||
</p>
|
||||
{fetcher.data?.error !== undefined ? (
|
||||
<Notice
|
||||
variant="error"
|
||||
title={fetcher.data.error.split(':')[0] ?? 'Error'}
|
||||
variant="error"
|
||||
>
|
||||
{fetcher.data.error.split(':').slice(1).join(': ') ??
|
||||
'An unknown error occurred while trying to update the ACL policy.'}
|
||||
</Notice>
|
||||
) : undefined}
|
||||
<Tabs label="ACL Editor" className="mb-4">
|
||||
<Tabs className="mb-4" label="ACL Editor">
|
||||
<Tabs.Item
|
||||
key="edit"
|
||||
title={
|
||||
@@ -110,8 +99,8 @@ export default function Page() {
|
||||
>
|
||||
<Editor
|
||||
isDisabled={disabled}
|
||||
value={codePolicy}
|
||||
onChange={setCodePolicy}
|
||||
value={codePolicy}
|
||||
/>
|
||||
</Tabs.Item>
|
||||
<Tabs.Item
|
||||
@@ -145,7 +134,6 @@ export default function Page() {
|
||||
</Tabs.Item>
|
||||
</Tabs>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="mr-2"
|
||||
isDisabled={
|
||||
disabled ||
|
||||
@@ -158,6 +146,7 @@ export default function Page() {
|
||||
formData.append('policy', codePolicy);
|
||||
fetcher.submit(formData, { method: 'PATCH' });
|
||||
}}
|
||||
variant="heavy"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { ActionFunctionArgs, data, redirect } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import ResponseError from '~/server/headscale/api-error';
|
||||
import { Key } from '~/types';
|
||||
import { data, redirect } from 'react-router';
|
||||
import ResponseError from '~/server/headscale/api/response-error';
|
||||
import log from '~/utils/log';
|
||||
import type { Route } from './+types/page';
|
||||
|
||||
export async function loginAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
const formData = await request.formData();
|
||||
const apiKey = formData.has('api_key')
|
||||
? String(formData.get('api_key'))
|
||||
@@ -31,11 +27,9 @@ export async function loginAction({
|
||||
throw data('Received an empty `api_key`', { status: 400 });
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
try {
|
||||
const { apiKeys } = await context.client.get<{ apiKeys: Key[] }>(
|
||||
'v1/apikey',
|
||||
apiKey,
|
||||
);
|
||||
const apiKeys = await api.getApiKeys();
|
||||
|
||||
// We don't need to check for 0 API keys because this request cannot
|
||||
// be authenticated correctly without an API key
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import { OidcConnectorError } from '~/server/web/oidc-connector';
|
||||
|
||||
export function OidcConfigErrorNotice({
|
||||
errors,
|
||||
}: {
|
||||
errors: OidcConnectorError[];
|
||||
}) {
|
||||
return (
|
||||
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title className="text-red-500">Authentication Error</Card.Title>
|
||||
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
||||
</div>
|
||||
<Card.Text className="text-sm">
|
||||
The OpenID Connect (OIDC) Single Sign-On (SSO) configuration has issues:{' '}
|
||||
<ul className="list-disc list-inside mt-2 mb-1">
|
||||
{mapOidcErrorsToMessages(errors).map((code) => (
|
||||
<li key={code.key}>{code.node}</li>
|
||||
))}
|
||||
</ul>{' '}
|
||||
<Link
|
||||
name="Headplane OIDC Issues"
|
||||
to="https://headplane.net/configuration/sso#help"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
|
||||
const messages: {
|
||||
key: string;
|
||||
node: React.ReactNode;
|
||||
}[] = [];
|
||||
|
||||
for (const error of errors) {
|
||||
switch (error) {
|
||||
case 'INVALID_API_KEY':
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
The provided API key for OIDC authentication is invalid. Ensure
|
||||
that <Code>oidc.headscale_api_key</Code> is a valid API key.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
break;
|
||||
|
||||
case 'MISSING_AUTHORIZATION_ENDPOINT':
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
The OIDC provided does not have a configured{' '}
|
||||
<Code>authorization_endpoint</Code>. Ensure discovery URL or
|
||||
manual configuration is correct.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
break;
|
||||
|
||||
case 'MISSING_TOKEN_ENDPOINT':
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
The OIDC provided does not have a configured{' '}
|
||||
<Code>token_endpoint</Code>. Ensure discovery URL or manual
|
||||
configuration is correct.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
break;
|
||||
|
||||
case 'MISSING_USERINFO_ENDPOINT':
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
The OIDC provided does not have a configured{' '}
|
||||
<Code>user_endpoint</Code>. Ensure discovery URL or manual
|
||||
configuration is correct.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
break;
|
||||
|
||||
case 'MISSING_REQUIRED_CLAIMS':
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
The OIDC provider does not support the <Code>sub</Code> claim,
|
||||
which is required for authentication. Your OIDC provider may be
|
||||
misconfigured.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
break;
|
||||
|
||||
case 'UNKNOWN_ERROR':
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
An unknown error occurred during OIDC configuration. Please check
|
||||
the Headplane logs for more information.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
|
||||
export function OidcErrorNotice({ code }: { code: string }) {
|
||||
return (
|
||||
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title className="text-red-500">Configuration Issue(s)</Card.Title>
|
||||
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
||||
</div>
|
||||
{getErrorMessage(code)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function getErrorMessage(code: string) {
|
||||
switch (code) {
|
||||
case 'error_no_query':
|
||||
return (
|
||||
<Card.Text>
|
||||
The SSO provider did not correctly redirect back to Headplane with the
|
||||
required parameters. Please ensure your SSO provider is configured
|
||||
correctly.
|
||||
</Card.Text>
|
||||
);
|
||||
|
||||
case 'error_no_session':
|
||||
case 'error_invalid_session':
|
||||
return (
|
||||
<Card.Text>
|
||||
Unable to complete SSO login due to missing or invalid session data.
|
||||
Ensure that your Headplane cookie configuration is correct and that
|
||||
your browser is accepting cookies.
|
||||
</Card.Text>
|
||||
);
|
||||
|
||||
case 'error_no_sub':
|
||||
return (
|
||||
<Card.Text>
|
||||
The SSO provider did not return a valid user identifier. Please ensure
|
||||
your SSO provider is correctly configured to provide the{' '}
|
||||
<Code>sub</Code> claim.
|
||||
</Card.Text>
|
||||
);
|
||||
|
||||
case 'error_auth_failed':
|
||||
return (
|
||||
<Card.Text>
|
||||
Authentication with the SSO provider failed. Please tray again later.
|
||||
Headplane logs may provide more information.
|
||||
</Card.Text>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Card.Text>
|
||||
An unknown error occurred during OIDC authentication. Please try again
|
||||
later.
|
||||
</Card.Text>
|
||||
);
|
||||
}
|
||||
}
|
||||
+104
-69
@@ -1,78 +1,80 @@
|
||||
import { useEffect } from 'react';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
data,
|
||||
Form,
|
||||
LoaderFunctionArgs,
|
||||
Link as RemixLink,
|
||||
redirect,
|
||||
useActionData,
|
||||
useLoaderData,
|
||||
useSearchParams,
|
||||
} from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Code from '~/components/Code';
|
||||
import Input from '~/components/Input';
|
||||
import type { LoadContext } from '~/server';
|
||||
import Link from '~/components/Link';
|
||||
import { OidcConnectorError } from '~/server/web/oidc-connector';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import type { Route } from './+types/page';
|
||||
import { loginAction } from './action';
|
||||
import { OidcConfigErrorNotice } from './config-error';
|
||||
import Logout from './logout';
|
||||
import { OidcErrorNotice } from './oidc-error';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
await context.sessions.auth(request);
|
||||
return redirect('/machines');
|
||||
} catch {}
|
||||
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const state = qp.get('s') ?? undefined;
|
||||
|
||||
// OIDC config cannot be undefined if an OIDC client is set
|
||||
// Also check if we are in a logout state and skip redirect if we are
|
||||
const ssoOnly = context.config.oidc?.disable_api_key_login;
|
||||
if (state !== 'logout' && ssoOnly) {
|
||||
// This shouldn't be possible, but still a safe sanity check
|
||||
if (!context.oidc) {
|
||||
throw data(
|
||||
'`oidc.disable_api_key_login` was set without a valid OIDC configuration',
|
||||
{
|
||||
status: 400,
|
||||
},
|
||||
);
|
||||
}
|
||||
const urlState = qp.get('s') ?? undefined;
|
||||
|
||||
// MARK: This works because the OIDC connector will always return false
|
||||
// for `isExclusive` if the OIDC config isn't usable.
|
||||
if (context.oidcConnector?.isExclusive && urlState !== 'logout') {
|
||||
return redirect('/oidc/start');
|
||||
}
|
||||
|
||||
const isOidcConnectorEnabled = context.oidcConnector?.isValid;
|
||||
const oidcErrorCodes = !isOidcConnectorEnabled
|
||||
? context.oidcConnector!.errors
|
||||
: [];
|
||||
|
||||
return {
|
||||
oidc: context.oidc,
|
||||
state,
|
||||
isCookieSecureEnabled: context.config.server.cookie_secure,
|
||||
isOidcConnectorEnabled,
|
||||
oidcErrorCodes,
|
||||
urlState,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(request: ActionFunctionArgs<LoadContext>) {
|
||||
return loginAction(request);
|
||||
}
|
||||
export const action = loginAction;
|
||||
|
||||
export default function Page() {
|
||||
const { state, oidc } = useLoaderData<typeof loader>();
|
||||
const formData = useActionData<typeof action>();
|
||||
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
|
||||
const {
|
||||
isCookieSecureEnabled,
|
||||
isOidcConnectorEnabled,
|
||||
oidcErrorCodes,
|
||||
urlState,
|
||||
} = loaderData;
|
||||
|
||||
const [showCookieWarning, setShowCookieWarning] = useState(false);
|
||||
const [params] = useSearchParams();
|
||||
const { pause } = useLiveData();
|
||||
|
||||
useEffect(() => {
|
||||
// This page does NOT need stale while revalidate logic
|
||||
pause();
|
||||
|
||||
if (isCookieSecureEnabled && window.location.protocol !== 'https:') {
|
||||
setShowCookieWarning(true);
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// State is a one time thing, we need to remove it after it has
|
||||
// been consumed to prevent logic loops.
|
||||
if (state !== null) {
|
||||
if (urlState !== null) {
|
||||
const searchParams = new URLSearchParams(params);
|
||||
searchParams.delete('s');
|
||||
|
||||
@@ -85,48 +87,81 @@ export default function Page() {
|
||||
|
||||
window.history.replaceState(null, '', newUrl);
|
||||
}
|
||||
}, [state, params]);
|
||||
}, [urlState, params]);
|
||||
|
||||
if (state === 'logout') {
|
||||
if (urlState === 'logout') {
|
||||
return <Logout />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-screen h-screen items-center justify-center">
|
||||
<Card className="max-w-md m-4 sm:m-0">
|
||||
<Card.Title>Welcome to Headplane</Card.Title>
|
||||
<Form method="POST">
|
||||
<Card.Text>
|
||||
Enter an API key to authenticate with Headplane. You can generate
|
||||
one by running <Code>headscale apikeys create</Code> in your
|
||||
terminal.
|
||||
</Card.Text>
|
||||
<Input
|
||||
className="mt-8 mb-2"
|
||||
isRequired
|
||||
label="API Key"
|
||||
labelHidden
|
||||
name="api_key"
|
||||
placeholder="API Key"
|
||||
type="password"
|
||||
/>
|
||||
{formData?.success === false ? (
|
||||
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
|
||||
{formData.message}
|
||||
</Card.Text>
|
||||
) : undefined}
|
||||
<Button className="w-full" type="submit" variant="heavy">
|
||||
Sign In
|
||||
</Button>
|
||||
</Form>
|
||||
{oidc ? (
|
||||
<RemixLink to="/oidc/start">
|
||||
<Button className="w-full mt-2" variant="light">
|
||||
Single Sign-On
|
||||
</Button>
|
||||
</RemixLink>
|
||||
<div>
|
||||
{urlState?.startsWith('error_') ? (
|
||||
<OidcErrorNotice code={urlState} />
|
||||
) : oidcErrorCodes.length > 0 ? (
|
||||
<OidcConfigErrorNotice errors={oidcErrorCodes} />
|
||||
) : showCookieWarning ? (
|
||||
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title className="text-red-500">
|
||||
Configuration Issue
|
||||
</Card.Title>
|
||||
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
|
||||
</div>
|
||||
{showCookieWarning ? (
|
||||
<Card.Text className="text-sm">
|
||||
Headplane is configured to use secure cookies, but this site is
|
||||
being served over an insecure connection and login will not work
|
||||
correctly.{' '}
|
||||
<Link
|
||||
name="Headplane Common Issues"
|
||||
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
|
||||
>
|
||||
Learn more.
|
||||
</Link>
|
||||
</Card.Text>
|
||||
) : undefined}
|
||||
</Card>
|
||||
) : undefined}
|
||||
</Card>
|
||||
<Card className="max-w-md m-4 sm:m-0">
|
||||
<Card.Title>Welcome to Headplane</Card.Title>
|
||||
<Form method="POST">
|
||||
<Card.Text>
|
||||
Enter an API key to authenticate with Headplane. You can generate
|
||||
one by running <Code>headscale apikeys create</Code> in your
|
||||
terminal.
|
||||
</Card.Text>
|
||||
<Input
|
||||
className="mt-8 mb-2"
|
||||
isRequired
|
||||
label="API Key"
|
||||
labelHidden
|
||||
name="api_key"
|
||||
placeholder="API Key"
|
||||
type="password"
|
||||
/>
|
||||
{actionData?.success === false ? (
|
||||
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
|
||||
{actionData.message}
|
||||
</Card.Text>
|
||||
) : undefined}
|
||||
<Button className="w-full" type="submit" variant="heavy">
|
||||
Sign In
|
||||
</Button>
|
||||
</Form>
|
||||
{isOidcConnectorEnabled ? (
|
||||
<RemixLink to="/oidc/start">
|
||||
<Button
|
||||
className="w-full mt-2"
|
||||
isDisabled={oidcErrorCodes.length > 0}
|
||||
variant="light"
|
||||
>
|
||||
Single Sign-On
|
||||
</Button>
|
||||
</RemixLink>
|
||||
) : undefined}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,75 +1,99 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { count, eq } from 'drizzle-orm';
|
||||
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import * as oidc from 'openid-client';
|
||||
import {
|
||||
createCookie,
|
||||
data,
|
||||
type LoaderFunctionArgs,
|
||||
redirect,
|
||||
} from 'react-router';
|
||||
import { ulid } from 'ulidx';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { HeadplaneConfig } from '~/server/config/schema';
|
||||
import { users } from '~/server/db/schema';
|
||||
import { Roles } from '~/server/web/roles';
|
||||
import { FlowUser, finishAuthFlow, formatError } from '~/utils/oidc';
|
||||
import { send } from '~/utils/res';
|
||||
|
||||
interface OidcFlowSession {
|
||||
state: string;
|
||||
nonce: string;
|
||||
code_verifier: string;
|
||||
redirect_uri: string;
|
||||
}
|
||||
import log from '~/utils/log';
|
||||
import type { OidcCookieState } from './oidc-start';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
if (!context.oidc) {
|
||||
throw new Error('OIDC is not enabled');
|
||||
if (!context.oidcConnector?.isValid) {
|
||||
throw data('OIDC is not enabled or misconfigured', { status: 501 });
|
||||
}
|
||||
|
||||
// Check if we have 0 query parameters
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.toString().length === 0) {
|
||||
return redirect('/login');
|
||||
return redirect('/login?s=error_no_query');
|
||||
}
|
||||
|
||||
const cookie = createCookie('__oidc_auth_flow', {
|
||||
httpOnly: true,
|
||||
maxAge: 300, // 5 minutes
|
||||
maxAge: 300,
|
||||
secure: context.config.server.cookie_secure,
|
||||
domain: context.config.server.cookie_domain,
|
||||
});
|
||||
|
||||
const data: OidcFlowSession | null = await cookie.parse(
|
||||
const oidcCookieState: OidcCookieState | null = await cookie.parse(
|
||||
request.headers.get('Cookie'),
|
||||
);
|
||||
|
||||
if (data === null) {
|
||||
console.warn('OIDC flow session not found');
|
||||
return redirect('/login');
|
||||
if (oidcCookieState == null || typeof oidcCookieState !== 'object') {
|
||||
log.warn('auth', 'Called OIDC callback without session cookie');
|
||||
return redirect('/login?s=error_no_session');
|
||||
}
|
||||
|
||||
const { code_verifier, state, nonce, redirect_uri } = data;
|
||||
if (!code_verifier || !state || !nonce || !redirect_uri) {
|
||||
return send({ error: 'Missing OIDC state' }, { status: 400 });
|
||||
const { state, nonce } = oidcCookieState;
|
||||
if (!state || !nonce) {
|
||||
log.warn('auth', 'OIDC session cookie is missing required fields');
|
||||
return redirect('/login?s=error_invalid_session');
|
||||
}
|
||||
|
||||
// Reconstruct the redirect URI using the query parameters
|
||||
// and the one we saved in the session
|
||||
const flowRedirectUri = new URL(redirect_uri);
|
||||
flowRedirectUri.search = url.search;
|
||||
|
||||
const flowOptions = {
|
||||
redirect_uri: flowRedirectUri.toString(),
|
||||
code_verifier,
|
||||
state,
|
||||
nonce: nonce === '<none>' ? undefined : nonce,
|
||||
};
|
||||
|
||||
try {
|
||||
let user = await finishAuthFlow(context.oidc, flowOptions);
|
||||
user = {
|
||||
...user,
|
||||
picture: setOidcPictureForSource(
|
||||
user,
|
||||
context.config.oidc?.profile_picture_source ?? 'oidc',
|
||||
),
|
||||
};
|
||||
const tokens = await oidc.authorizationCodeGrant(
|
||||
context.oidcConnector.client,
|
||||
request,
|
||||
{
|
||||
expectedState: state,
|
||||
expectedNonce: nonce,
|
||||
},
|
||||
);
|
||||
|
||||
const claims = tokens.claims();
|
||||
if (claims?.sub == null) {
|
||||
log.warn('auth', 'No subject found in OIDC claims');
|
||||
return redirect('/login?s=error_no_sub');
|
||||
}
|
||||
|
||||
const userInfo = await oidc.fetchUserInfo(
|
||||
context.oidcConnector.client,
|
||||
tokens.access_token,
|
||||
claims.sub,
|
||||
);
|
||||
|
||||
// We have defaults that closely follow what Headscale uses, maybe we
|
||||
// can make it configurable in the future, but for now we only need the
|
||||
// `sub` claim.
|
||||
const username =
|
||||
userInfo.preferred_username ?? userInfo.email?.split('@')[0] ?? 'user';
|
||||
const name =
|
||||
userInfo.name ??
|
||||
(userInfo.given_name && userInfo.family_name
|
||||
? `${userInfo.given_name} ${userInfo.family_name}`
|
||||
: (userInfo.preferred_username ?? 'SSO User'));
|
||||
|
||||
const picture =
|
||||
context.config.oidc?.profile_picture_source === 'gravatar'
|
||||
? (() => {
|
||||
if (!userInfo.email) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const emailHash = userInfo.email.trim().toLowerCase();
|
||||
const hash = createHash('sha256').update(emailHash).digest('hex');
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
|
||||
})()
|
||||
: userInfo.picture;
|
||||
|
||||
const [{ count: userCount }] = await context.db
|
||||
.select({ count: count() })
|
||||
@@ -80,52 +104,46 @@ export async function loader({
|
||||
.insert(users)
|
||||
.values({
|
||||
id: ulid(),
|
||||
sub: user.subject,
|
||||
sub: claims.sub,
|
||||
caps: userCount === 0 ? Roles.owner : Roles.member,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
return redirect('/machines', {
|
||||
return redirect('/', {
|
||||
headers: {
|
||||
'Set-Cookie': await context.sessions.createSession({
|
||||
// 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.
|
||||
api_key: context.config.oidc?.headscale_api_key!,
|
||||
user,
|
||||
api_key: context.oidcConnector.apiKey,
|
||||
user: {
|
||||
subject: claims.sub,
|
||||
username,
|
||||
name,
|
||||
email: userInfo.email,
|
||||
picture,
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return new Response(JSON.stringify(formatError(error)), {
|
||||
status: 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type PictureSource = NonNullable<
|
||||
HeadplaneConfig['oidc']
|
||||
>['profile_picture_source'];
|
||||
|
||||
function setOidcPictureForSource(user: FlowUser, source: PictureSource) {
|
||||
// Already set by default in the callback, so we can just return it
|
||||
if (source === 'oidc') {
|
||||
return user.picture;
|
||||
}
|
||||
|
||||
if (source === 'gravatar') {
|
||||
if (!user.email) {
|
||||
return undefined;
|
||||
if (error instanceof oidc.ResponseBodyError) {
|
||||
log.error(
|
||||
'auth',
|
||||
'Got an OIDC response error body: %s',
|
||||
JSON.stringify(error.cause),
|
||||
);
|
||||
}
|
||||
|
||||
const emailHash = user.email.trim().toLowerCase();
|
||||
const hash = createHash('sha256').update(emailHash).digest('hex');
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
|
||||
}
|
||||
if (error instanceof oidc.AuthorizationResponseError) {
|
||||
log.error(
|
||||
'auth',
|
||||
'Got an OIDC authorization response error: %s',
|
||||
error.error,
|
||||
);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
if (error instanceof oidc.WWWAuthenticateChallengeError) {
|
||||
log.error('auth', 'Got an OIDC WWW-Authenticate challenge error');
|
||||
}
|
||||
|
||||
return redirect('/login?s=error_auth_failed');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import * as oidc from 'openid-client';
|
||||
import {
|
||||
createCookie,
|
||||
data,
|
||||
type LoaderFunctionArgs,
|
||||
redirect,
|
||||
} from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
|
||||
|
||||
export interface OidcCookieState {
|
||||
nonce: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
@@ -8,36 +18,63 @@ export async function loader({
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
try {
|
||||
await context.sessions.auth(request);
|
||||
return redirect('/machines');
|
||||
return redirect('/');
|
||||
} catch {}
|
||||
|
||||
if (!context.oidc || !context.config.oidc) {
|
||||
throw new Error('OIDC is not enabled');
|
||||
if (!context.oidcConnector?.isValid) {
|
||||
throw data('OIDC is not enabled or misconfigured', { status: 501 });
|
||||
}
|
||||
|
||||
const cookie = createCookie('__oidc_auth_flow', {
|
||||
httpOnly: true,
|
||||
maxAge: 300, // 5 minutes
|
||||
maxAge: 300,
|
||||
secure: context.config.server.cookie_secure,
|
||||
domain: context.config.server.cookie_domain,
|
||||
});
|
||||
|
||||
const redirectUri =
|
||||
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
|
||||
const data = await beginAuthFlow(
|
||||
context.oidc,
|
||||
redirectUri,
|
||||
context.config.oidc.scope,
|
||||
context.config.oidc.extra_params,
|
||||
);
|
||||
|
||||
return redirect(data.url, {
|
||||
const nonce = oidc.randomNonce();
|
||||
const state = oidc.randomState();
|
||||
|
||||
const url = oidc.buildAuthorizationUrl(context.oidcConnector.client, {
|
||||
...(context.oidcConnector.extraParams ?? {}),
|
||||
scope: context.oidcConnector.scope,
|
||||
redirect_uri: redirectUri,
|
||||
state,
|
||||
nonce,
|
||||
});
|
||||
|
||||
return redirect(url.href, {
|
||||
status: 302,
|
||||
headers: {
|
||||
'Set-Cookie': await cookie.serialize({
|
||||
state: data.state,
|
||||
nonce: data.nonce,
|
||||
code_verifier: data.codeVerifier,
|
||||
redirect_uri: redirectUri,
|
||||
}),
|
||||
state,
|
||||
nonce,
|
||||
} satisfies OidcCookieState),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getRedirectUri(req: Request) {
|
||||
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
|
||||
let host = req.headers.get('Host');
|
||||
if (!host) {
|
||||
host = req.headers.get('X-Forwarded-Host');
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
throw data(
|
||||
'Cannot determine redirect URI: no Host or X-Forwarded-Host header',
|
||||
{
|
||||
status: 500,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const proto = req.headers.get('X-Forwarded-Proto');
|
||||
url.protocol = proto ?? 'http:';
|
||||
url.host = host;
|
||||
return url.href;
|
||||
}
|
||||
|
||||
+205
-225
@@ -1,11 +1,8 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import { data } from 'react-router';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
|
||||
export async function dnsAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.write_network,
|
||||
@@ -19,6 +16,9 @@ export async function dnsAction({
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
// We only need it for health checks which don't require auth
|
||||
const api = context.hsApi.getRuntimeClient('fake-api-key');
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
@@ -26,226 +26,206 @@ export async function dnsAction({
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'rename_tailnet':
|
||||
return renameTailnet(formData, context);
|
||||
case 'toggle_magic':
|
||||
return toggleMagic(formData, context);
|
||||
case 'remove_ns':
|
||||
return removeNs(formData, context);
|
||||
case 'add_ns':
|
||||
return addNs(formData, context);
|
||||
case 'remove_domain':
|
||||
return removeDomain(formData, context);
|
||||
case 'add_domain':
|
||||
return addDomain(formData, context);
|
||||
case 'remove_record':
|
||||
return removeRecord(formData, context);
|
||||
case 'add_record':
|
||||
return addRecord(formData, context);
|
||||
case 'override_dns':
|
||||
return overrideDns(formData, context);
|
||||
case 'rename_tailnet': {
|
||||
const newName = formData.get('new_name')?.toString();
|
||||
if (!newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.base_domain',
|
||||
value: newName,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'Tailnet renamed successfully' };
|
||||
}
|
||||
case 'toggle_magic': {
|
||||
const newState = formData.get('new_state')?.toString();
|
||||
if (!newState) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.magic_dns',
|
||||
value: newState === 'enabled',
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'Magic DNS state updated successfully' };
|
||||
}
|
||||
case 'remove_ns': {
|
||||
const config = context.hs.c!;
|
||||
const ns = formData.get('ns')?.toString();
|
||||
const splitName = formData.get('split_name')?.toString();
|
||||
|
||||
if (!ns || !splitName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
if (splitName === 'global') {
|
||||
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.nameservers.global',
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName].filter((i) => i !== ns);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers.length > 0 ? servers : null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'Nameserver removed successfully' };
|
||||
}
|
||||
case 'add_ns': {
|
||||
const config = context.hs.c!;
|
||||
const ns = formData.get('ns')?.toString();
|
||||
const splitName = formData.get('split_name')?.toString();
|
||||
|
||||
if (!ns || !splitName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
if (splitName === 'global') {
|
||||
const servers = config.dns.nameservers.global;
|
||||
servers.push(ns);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.nameservers.global',
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName] ?? [];
|
||||
servers.push(ns);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'Nameserver added successfully' };
|
||||
}
|
||||
case 'remove_domain': {
|
||||
const config = context.hs.c!;
|
||||
const domain = formData.get('domain')?.toString();
|
||||
if (!domain) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const domains = config.dns.search_domains.filter((i) => i !== domain);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.search_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'Domain removed successfully' };
|
||||
}
|
||||
case 'add_domain': {
|
||||
const config = context.hs.c!;
|
||||
const domain = formData.get('domain')?.toString();
|
||||
if (!domain) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const domains = config.dns.search_domains;
|
||||
domains.push(domain);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.search_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'Domain added successfully' };
|
||||
}
|
||||
case 'remove_record': {
|
||||
const recordName = formData.get('record_name')?.toString();
|
||||
const recordType = formData.get('record_type')?.toString();
|
||||
|
||||
if (!recordName || !recordType) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
// Value is not needed for removal
|
||||
const restart = await context.hs.removeDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: '',
|
||||
});
|
||||
|
||||
if (!restart) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'DNS record removed successfully' };
|
||||
}
|
||||
case 'add_record': {
|
||||
const recordName = formData.get('record_name')?.toString();
|
||||
const recordType = formData.get('record_type')?.toString();
|
||||
const recordValue = formData.get('record_value')?.toString();
|
||||
|
||||
if (!recordName || !recordType || !recordValue) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const restart = await context.hs.addDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: recordValue,
|
||||
});
|
||||
|
||||
if (!restart) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'DNS record added successfully' };
|
||||
}
|
||||
case 'override_dns': {
|
||||
const override = formData.get('override_dns')?.toString();
|
||||
if (!override) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const overrideValue = override === 'true';
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.override_local_dns',
|
||||
value: overrideValue,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
return { message: 'DNS override updated successfully' };
|
||||
}
|
||||
default:
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameTailnet(formData: FormData, context: LoadContext) {
|
||||
const newName = formData.get('new_name')?.toString();
|
||||
if (!newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.base_domain',
|
||||
value: newName,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function toggleMagic(formData: FormData, context: LoadContext) {
|
||||
const newState = formData.get('new_state')?.toString();
|
||||
if (!newState) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.magic_dns',
|
||||
value: newState === 'enabled',
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (!ns || !splitName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
if (splitName === 'global') {
|
||||
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.nameservers.global',
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName].filter((i) => i !== ns);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers.length > 0 ? servers : null,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (!ns || !splitName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
if (splitName === 'global') {
|
||||
const servers = config.dns.nameservers.global;
|
||||
servers.push(ns);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.nameservers.global',
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
const splits = config.dns.nameservers.split;
|
||||
const servers = splits[splitName] ?? [];
|
||||
servers.push(ns);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: `dns.nameservers.split."${splitName}"`,
|
||||
value: servers,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
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 domains = config.dns.search_domains.filter((i) => i !== domain);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.search_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
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 domains = config.dns.search_domains;
|
||||
domains.push(domain);
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.search_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (!recordName || !recordType) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
// Value is not needed for removal
|
||||
const restart = await context.hs.removeDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: '',
|
||||
});
|
||||
|
||||
if (!restart) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
if (!recordName || !recordType || !recordValue) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const restart = await context.hs.addDNS({
|
||||
name: recordName,
|
||||
type: recordType,
|
||||
value: recordValue,
|
||||
});
|
||||
|
||||
if (!restart) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
async function overrideDns(formData: FormData, context: LoadContext) {
|
||||
const override = formData.get('override_dns')?.toString();
|
||||
if (!override) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const overrideValue = override === 'true';
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'dns.override_local_dns',
|
||||
value: overrideValue,
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(context.client);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ interface Props {
|
||||
isAgent?: boolean;
|
||||
magic?: string;
|
||||
isDisabled?: boolean;
|
||||
existingTags?: string[];
|
||||
}
|
||||
|
||||
export default function MachineRow({
|
||||
@@ -30,6 +31,7 @@ export default function MachineRow({
|
||||
isAgent,
|
||||
magic,
|
||||
isDisabled,
|
||||
existingTags,
|
||||
}: Props) {
|
||||
const uiTags = useMemo(() => {
|
||||
const tags = uiTagsForNode(node, isAgent);
|
||||
@@ -64,7 +66,10 @@ export default function MachineRow({
|
||||
{node.givenName}
|
||||
</p>
|
||||
<p className="text-sm opacity-50">
|
||||
{node.user.name || node.user.displayName || node.user.email || node.user.id}
|
||||
{node.user.name ||
|
||||
node.user.displayName ||
|
||||
node.user.email ||
|
||||
node.user.id}
|
||||
</p>
|
||||
<div className="flex gap-1 flex-wrap mt-1.5">
|
||||
{mapTagsToComponents(node, uiTags)}
|
||||
@@ -143,6 +148,7 @@ export default function MachineRow({
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
<MenuOptions
|
||||
existingTags={existingTags}
|
||||
isDisabled={isDisabled}
|
||||
magic={magic}
|
||||
node={node}
|
||||
|
||||
@@ -11,12 +11,14 @@ import Move from '../dialogs/move';
|
||||
import Rename from '../dialogs/rename';
|
||||
import Routes from '../dialogs/routes';
|
||||
import Tags from '../dialogs/tags';
|
||||
|
||||
interface MenuProps {
|
||||
node: PopulatedNode;
|
||||
users: User[];
|
||||
magic?: string;
|
||||
isFullButton?: boolean;
|
||||
isDisabled?: boolean;
|
||||
existingTags?: string[];
|
||||
}
|
||||
|
||||
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
|
||||
@@ -27,6 +29,7 @@ export default function MachineMenu({
|
||||
users,
|
||||
isFullButton,
|
||||
isDisabled,
|
||||
existingTags,
|
||||
}: MenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
const supportsTailscaleSSH =
|
||||
@@ -36,8 +39,8 @@ export default function MachineMenu({
|
||||
<div className="flex items-center justify-end px-4 gap-1.5">
|
||||
{modal === 'remove' && (
|
||||
<Delete
|
||||
machine={node}
|
||||
isOpen={modal === 'remove'}
|
||||
machine={node}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
@@ -45,19 +48,19 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'move' && (
|
||||
<Move
|
||||
machine={node}
|
||||
users={users}
|
||||
isOpen={modal === 'move'}
|
||||
machine={node}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
users={users}
|
||||
/>
|
||||
)}
|
||||
{modal === 'rename' && (
|
||||
<Rename
|
||||
isOpen={modal === 'rename'}
|
||||
machine={node}
|
||||
magic={magic}
|
||||
isOpen={modal === 'rename'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
@@ -65,8 +68,8 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'routes' && (
|
||||
<Routes
|
||||
node={node}
|
||||
isOpen={modal === 'routes'}
|
||||
node={node}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
@@ -74,8 +77,9 @@ export default function MachineMenu({
|
||||
)}
|
||||
{modal === 'tags' && (
|
||||
<Tags
|
||||
machine={node}
|
||||
existingTags={existingTags}
|
||||
isOpen={modal === 'tags'}
|
||||
machine={node}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
@@ -83,8 +87,8 @@ export default function MachineMenu({
|
||||
)}
|
||||
{node.expired && modal === 'expire' ? undefined : (
|
||||
<Expire
|
||||
machine={node}
|
||||
isOpen={modal === 'expire'}
|
||||
machine={node}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
@@ -95,33 +99,23 @@ export default function MachineMenu({
|
||||
isFullButton ? (
|
||||
<Button
|
||||
className="flex items-center gap-x-2"
|
||||
variant="heavy"
|
||||
onPress={() => {
|
||||
// We need to use JS to open the SSH URL
|
||||
// in a new WINDOW since href can only
|
||||
// do a new TAB.
|
||||
window.open(
|
||||
`${__PREFIX__}/ssh?hostname=${node.name}`,
|
||||
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer,width=800,height=600',
|
||||
);
|
||||
}}
|
||||
variant="heavy"
|
||||
>
|
||||
<SquareTerminal className="h-5" />
|
||||
<p>SSH</p>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onPress={() => {
|
||||
// We need to use JS to open the SSH URL
|
||||
// in a new WINDOW since href can only
|
||||
// do a new TAB.
|
||||
window.open(
|
||||
`${__PREFIX__}/ssh?hostname=${node.name}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer,width=800,height=600',
|
||||
);
|
||||
}}
|
||||
className={cn(
|
||||
'py-0.5 w-fit bg-transparent border-transparent',
|
||||
'border group-hover:border-headplane-200',
|
||||
@@ -129,6 +123,16 @@ export default function MachineMenu({
|
||||
'opacity-0 pointer-events-none group-hover:opacity-100',
|
||||
'group-hover:pointer-events-auto',
|
||||
)}
|
||||
onPress={() => {
|
||||
// We need to use JS to open the SSH URL
|
||||
// in a new WINDOW since href can only
|
||||
// do a new TAB.
|
||||
window.open(
|
||||
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
||||
'_blank',
|
||||
'noopener,noreferrer,width=800,height=600',
|
||||
);
|
||||
}}
|
||||
>
|
||||
SSH
|
||||
</Button>
|
||||
@@ -142,19 +146,19 @@ export default function MachineMenu({
|
||||
</Menu.Button>
|
||||
) : (
|
||||
<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',
|
||||
)}
|
||||
label="Machine Options"
|
||||
>
|
||||
<Ellipsis className="h-5" />
|
||||
</Menu.IconButton>
|
||||
)}
|
||||
<Menu.Panel
|
||||
onAction={(key) => setModal(key as Modal)}
|
||||
disabledKeys={node.expired ? ['expire'] : []}
|
||||
onAction={(key) => setModal(key as Modal)}
|
||||
>
|
||||
<Menu.Section>
|
||||
<Menu.Item key="rename">Edit machine name</Menu.Item>
|
||||
|
||||
@@ -14,16 +14,16 @@ export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
<Dialog.Panel
|
||||
variant="destructive"
|
||||
onSubmit={() => navigate('/machines')}
|
||||
variant="destructive"
|
||||
>
|
||||
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
|
||||
<Dialog.Text>
|
||||
This machine will be permanently removed from your network. To re-add
|
||||
it, you will need to reauthenticate to your tailnet from the device.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="delete" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<input name="action_id" type="hidden" value="delete" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -20,21 +20,23 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
|
||||
<Dialog.Text>
|
||||
The owner of the machine is the user associated with it.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="reassign" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<input type="hidden" name="user_id" value={userId?.toString()} />
|
||||
<input name="action_id" type="hidden" value="reassign" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<input name="user_id" type="hidden" value={userId?.toString()} />
|
||||
<Select
|
||||
defaultSelectedKey={machine.user.id}
|
||||
isRequired
|
||||
label="Owner"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
defaultSelectedKey={machine.user.id}
|
||||
onSelectionChange={(key) => {
|
||||
setUserId(key);
|
||||
}}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{users.map((user) => (
|
||||
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
|
||||
@@ -30,14 +30,14 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
<Code isCopyable>tailscale up --login-server={data.server}</Code> on
|
||||
your device.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="register" />
|
||||
<input name="action_id" type="hidden" value="register" />
|
||||
<Input
|
||||
isRequired
|
||||
label="Machine Key"
|
||||
placeholder="AbCd..."
|
||||
validationBehavior="native"
|
||||
name="register_key"
|
||||
onChange={setMkey}
|
||||
placeholder="AbCd..."
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Select
|
||||
isRequired
|
||||
@@ -46,12 +46,14 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
<Menu isDisabled={data.isDisabled} disabledKeys={data.disabledKeys}>
|
||||
<Menu disabledKeys={data.disabledKeys} isDisabled={data.isDisabled}>
|
||||
<Menu.Button variant="heavy">Add Device</Menu.Button>
|
||||
<Menu.Panel
|
||||
onAction={(key) => {
|
||||
|
||||
@@ -27,16 +27,15 @@ export default function Rename({
|
||||
This name is shown in the admin panel, in Tailscale clients, and used
|
||||
when generating MagicDNS names.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="rename" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<input name="action_id" type="hidden" value="rename" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<Input
|
||||
defaultValue={machine.givenName}
|
||||
isRequired
|
||||
label="Machine name"
|
||||
placeholder="Machine name"
|
||||
validationBehavior="native"
|
||||
name="name"
|
||||
defaultValue={machine.givenName}
|
||||
onChange={setName}
|
||||
placeholder="Machine name"
|
||||
validate={(value) => {
|
||||
if (value.length === 0) {
|
||||
return 'Cannot be empty';
|
||||
@@ -66,6 +65,7 @@ export default function Rename({
|
||||
return 'Cannot contain consecutive hyphens';
|
||||
}
|
||||
}}
|
||||
validationBehavior="native"
|
||||
/>
|
||||
{magic ? (
|
||||
name.length > 0 && name !== machine.givenName ? (
|
||||
|
||||
@@ -30,8 +30,8 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
|
||||
Connect to devices you can't install Tailscale on by advertising
|
||||
IP ranges as subnet routes.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1019/subnets"
|
||||
name="Tailscale Subnets Documentation"
|
||||
to="https://tailscale.com/kb/1019/subnets"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
@@ -70,8 +70,8 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
|
||||
<Dialog.Text>
|
||||
Allow your network to route internet traffic through this machine.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1103/exit-nodes"
|
||||
name="Tailscale Exit-node Documentation"
|
||||
to="https://tailscale.com/kb/1103/exit-nodes"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Plus, TagsIcon, X } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import Button from '~/components/Button';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Input from '~/components/Input';
|
||||
import Link from '~/components/Link';
|
||||
import Select from '~/components/Select';
|
||||
import TableList from '~/components/TableList';
|
||||
import type { Machine } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
@@ -12,11 +12,24 @@ interface TagsProps {
|
||||
machine: Machine;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
existingTags?: string[];
|
||||
}
|
||||
|
||||
export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
||||
export default function Tags({
|
||||
machine,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
existingTags,
|
||||
}: TagsProps) {
|
||||
const [tags, setTags] = useState(machine.forcedTags);
|
||||
const [tag, setTag] = useState('');
|
||||
const [tag, setTag] = useState('tag:');
|
||||
const tagIsInvalid = useMemo(() => {
|
||||
return tag.length === 0 || !tag.startsWith('tag:') || tags.includes(tag);
|
||||
}, [tag, tags]);
|
||||
|
||||
const validNodeTags = useMemo(() => {
|
||||
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
|
||||
}, [tags]);
|
||||
|
||||
return (
|
||||
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
|
||||
@@ -26,16 +39,16 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
||||
ACL tags can be used to reference machines in your ACL policies. See
|
||||
the{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1068/acl-tags"
|
||||
name="Tailscale documentation"
|
||||
to="https://tailscale.com/kb/1068/acl-tags"
|
||||
>
|
||||
Tailscale documentation
|
||||
</Link>{' '}
|
||||
for more information.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="update_tags" />
|
||||
<input type="hidden" name="node_id" value={machine.id} />
|
||||
<input type="hidden" name="tags" value={tags.join(',')} />
|
||||
<input name="action_id" type="hidden" value="update_tags" />
|
||||
<input name="node_id" type="hidden" value={machine.id} />
|
||||
<input name="tags" type="hidden" value={tags.join(',')} />
|
||||
<TableList className="mt-4">
|
||||
{tags.length === 0 ? (
|
||||
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
|
||||
@@ -44,7 +57,7 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
||||
</TableList.Item>
|
||||
) : (
|
||||
tags.map((item) => (
|
||||
<TableList.Item className="font-mono" key={item} id={item}>
|
||||
<TableList.Item className="font-mono" id={item} key={item}>
|
||||
{item}
|
||||
<Button
|
||||
className="rounded-md p-0.5"
|
||||
@@ -57,44 +70,36 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
|
||||
</TableList.Item>
|
||||
))
|
||||
)}
|
||||
<TableList.Item
|
||||
className={cn(
|
||||
'rounded-b-xl focus-within:ring-3',
|
||||
tag.length > 0 &&
|
||||
(!tag.startsWith('tag:') || tags.includes(tag)) &&
|
||||
'ring-3 ring-red-500 ring-opacity-50',
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
labelHidden
|
||||
label="Add a tag"
|
||||
placeholder="tag:example"
|
||||
onChange={setTag}
|
||||
className={cn(
|
||||
'border-none font-mono p-0',
|
||||
'rounded-none focus:ring-0 w-full',
|
||||
)}
|
||||
/>
|
||||
<Button
|
||||
className={cn(
|
||||
'rounded-md p-0.5',
|
||||
(!tag.startsWith('tag:') || tags.includes(tag)) &&
|
||||
'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={
|
||||
tag.length === 0 ||
|
||||
!tag.startsWith('tag:') ||
|
||||
tags.includes(tag)
|
||||
}
|
||||
onPress={() => {
|
||||
setTags([...tags, tag]);
|
||||
setTag('');
|
||||
}}
|
||||
>
|
||||
<Plus className="p-1" />
|
||||
</Button>
|
||||
</TableList.Item>
|
||||
</TableList>
|
||||
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Select
|
||||
allowsCustomValue
|
||||
aria-label="Add a tag"
|
||||
className="w-full"
|
||||
inputValue={tag}
|
||||
isInvalid={tag.length > 0 && tagIsInvalid}
|
||||
onInputChange={setTag}
|
||||
placeholder="tag:example"
|
||||
>
|
||||
{validNodeTags.map((nodeTag) => {
|
||||
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
|
||||
})}
|
||||
</Select>
|
||||
<Button
|
||||
className={cn(
|
||||
'rounded-md p-1',
|
||||
tagIsInvalid && 'opacity-50 cursor-not-allowed',
|
||||
)}
|
||||
isDisabled={tagIsInvalid}
|
||||
onPress={() => {
|
||||
setTags([...tags, tag]);
|
||||
setTag('tag:');
|
||||
}}
|
||||
>
|
||||
<Plus className="p-1" size={30} />
|
||||
</Button>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { type ActionFunctionArgs, data, redirect } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { data, redirect } from 'react-router';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { Machine } from '~/types';
|
||||
import type { Route } from './+types/machine';
|
||||
|
||||
export async function machineAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
@@ -14,7 +10,7 @@ export async function machineAction({
|
||||
);
|
||||
|
||||
const formData = await request.formData();
|
||||
const apiKey = session.api_key;
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
@@ -31,7 +27,22 @@ export async function machineAction({
|
||||
});
|
||||
}
|
||||
|
||||
return registerMachine(formData, apiKey, context);
|
||||
const registrationKey = formData.get('register_key')?.toString();
|
||||
if (!registrationKey) {
|
||||
throw data('Missing `register_key` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = formData.get('user')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const node = await api.registerNode(user, registrationKey);
|
||||
return redirect(`/machines/${node.id}`);
|
||||
}
|
||||
|
||||
// Check if the user has permission to manage this machine
|
||||
@@ -42,12 +53,7 @@ export async function machineAction({
|
||||
});
|
||||
}
|
||||
|
||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const node = nodes.find((node) => node.id === nodeId);
|
||||
const node = await api.getNode(nodeId);
|
||||
if (!node) {
|
||||
throw data(`Machine with ID ${nodeId} not found`, {
|
||||
status: 404,
|
||||
@@ -65,27 +71,103 @@ export async function machineAction({
|
||||
|
||||
switch (action) {
|
||||
case 'rename': {
|
||||
return renameMachine(formData, apiKey, nodeId, context);
|
||||
const newName = formData.get('name')?.toString();
|
||||
if (!newName) {
|
||||
throw data('Missing `name` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const name = String(formData.get('name'));
|
||||
await api.renameNode(nodeId, name);
|
||||
return { message: 'Machine renamed' };
|
||||
}
|
||||
|
||||
case 'delete': {
|
||||
return deleteMachine(apiKey, nodeId, context);
|
||||
await api.deleteNode(nodeId);
|
||||
return redirect('/machines');
|
||||
}
|
||||
|
||||
case 'expire': {
|
||||
return expireMachine(apiKey, nodeId, context);
|
||||
await api.expireNode(nodeId);
|
||||
return { message: 'Machine expired' };
|
||||
}
|
||||
|
||||
case 'update_tags': {
|
||||
return updateTags(formData, apiKey, nodeId, context);
|
||||
const tags = formData.get('tags')?.toString().split(',') ?? [];
|
||||
if (tags.length === 0) {
|
||||
throw data('Missing `tags` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.setNodeTags(
|
||||
nodeId,
|
||||
tags.map((tag) => tag.trim()).filter((tag) => tag !== ''),
|
||||
);
|
||||
return { message: 'Tags updated' };
|
||||
}
|
||||
|
||||
case 'update_routes': {
|
||||
return updateRoutes(formData, apiKey, nodeId, context);
|
||||
const newApproved = node.approvedRoutes;
|
||||
const routes = formData.get('routes')?.toString();
|
||||
if (!routes) {
|
||||
throw data('Missing `routes` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const allRoutes = routes.split(',').map((route) => route.trim());
|
||||
if (allRoutes.length === 0) {
|
||||
throw data('No routes provided to update', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const enabled = formData.get('enabled')?.toString();
|
||||
if (enabled === undefined) {
|
||||
throw data('Missing `enabled` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (enabled === 'true') {
|
||||
for (const route of allRoutes) {
|
||||
// If already approved, skip, otherwise add to approved
|
||||
if (newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newApproved.push(route);
|
||||
}
|
||||
} else {
|
||||
for (const route of allRoutes) {
|
||||
// If not approved, skip, otherwise remove from approved
|
||||
if (!newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const index = newApproved.indexOf(route);
|
||||
if (index > -1) {
|
||||
newApproved.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await api.approveNodeRoutes(nodeId, newApproved);
|
||||
return { message: 'Routes updated' };
|
||||
}
|
||||
|
||||
case 'reassign': {
|
||||
return reassignMachine(formData, apiKey, nodeId, context);
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.setNodeUser(nodeId, user);
|
||||
return { message: 'Machine reassigned' };
|
||||
}
|
||||
|
||||
default:
|
||||
@@ -94,173 +176,3 @@ export async function machineAction({
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function registerMachine(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const registrationKey = formData.get('register_key')?.toString();
|
||||
if (!registrationKey) {
|
||||
throw data('Missing `register_key` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = formData.get('user')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const qp = new URLSearchParams();
|
||||
qp.append('user', user);
|
||||
qp.append('key', registrationKey);
|
||||
const url = `v1/node/register?${qp.toString()}`;
|
||||
const { node } = await context.client.post<{ node: Machine }>(url, apiKey, {
|
||||
user,
|
||||
key: registrationKey,
|
||||
});
|
||||
|
||||
return redirect(`/machines/${node.id}`);
|
||||
}
|
||||
|
||||
async function renameMachine(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const newName = formData.get('name')?.toString();
|
||||
if (!newName) {
|
||||
throw data('Missing `name` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const name = String(formData.get('name'));
|
||||
await context.client.post(`v1/node/${nodeId}/rename/${name}`, apiKey);
|
||||
return { message: 'Machine renamed' };
|
||||
}
|
||||
|
||||
async function deleteMachine(
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
await context.client.delete(`v1/node/${nodeId}`, apiKey);
|
||||
return redirect('/machines');
|
||||
}
|
||||
|
||||
async function expireMachine(
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
await context.client.post(`v1/node/${nodeId}/expire`, apiKey);
|
||||
return { message: 'Machine expired' };
|
||||
}
|
||||
|
||||
async function updateTags(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const tags = formData.get('tags')?.toString().split(',') ?? [];
|
||||
if (tags.length === 0) {
|
||||
throw data('Missing `tags` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.client.post(`v1/node/${nodeId}/tags`, apiKey, {
|
||||
tags: tags.map((tag) => tag.trim()).filter((tag) => tag !== ''),
|
||||
});
|
||||
|
||||
return { message: 'Tags updated' };
|
||||
}
|
||||
|
||||
async function updateRoutes(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const { node } = await context.client.get<{ node: Machine }>(
|
||||
`v1/node/${nodeId}`,
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const newApproved = node.approvedRoutes;
|
||||
const routes = formData.get('routes')?.toString();
|
||||
if (!routes) {
|
||||
throw data('Missing `routes` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const allRoutes = routes.split(',').map((route) => route.trim());
|
||||
if (allRoutes.length === 0) {
|
||||
throw data('No routes provided to update', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const enabled = formData.get('enabled')?.toString();
|
||||
if (enabled === undefined) {
|
||||
throw data('Missing `enabled` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (enabled === 'true') {
|
||||
for (const route of allRoutes) {
|
||||
// If already approved, skip, otherwise add to approved
|
||||
if (newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
newApproved.push(route);
|
||||
}
|
||||
} else {
|
||||
for (const route of allRoutes) {
|
||||
// If not approved, skip, otherwise remove from approved
|
||||
if (!newApproved.includes(route)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const index = newApproved.indexOf(route);
|
||||
if (index > -1) {
|
||||
newApproved.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await context.client.post(`v1/node/${nodeId}/approve_routes`, apiKey, {
|
||||
routes: newApproved,
|
||||
});
|
||||
|
||||
return { message: 'Routes updated' };
|
||||
}
|
||||
|
||||
async function reassignMachine(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
nodeId: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
throw data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.client.post(`v1/node/${nodeId}/user`, apiKey, {
|
||||
user,
|
||||
});
|
||||
|
||||
return { message: 'Machine reassigned' };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { Link as RemixLink, useLoaderData } from 'react-router';
|
||||
import { data, Link as RemixLink } from 'react-router';
|
||||
import Attribute from '~/components/Attribute';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
@@ -9,26 +8,25 @@ import Chip from '~/components/Chip';
|
||||
import Link from '~/components/Link';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { getOSInfo, getTSVersion } from '~/utils/host-info';
|
||||
import { mapNodes } from '~/utils/node-info';
|
||||
import { mapNodes, sortNodeTags } from '~/utils/node-info';
|
||||
import type { Route } from './+types/machine';
|
||||
import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
|
||||
import MenuOptions from './components/menu';
|
||||
import Routes from './dialogs/routes';
|
||||
import { machineAction } from './machine-actions';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
params,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
if (!params.id) {
|
||||
throw new Error('No machine ID provided');
|
||||
}
|
||||
|
||||
if (params.id.endsWith('.ico')) {
|
||||
throw data(null, { status: 204 });
|
||||
}
|
||||
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
if (context.hs.c?.dns.magic_dns) {
|
||||
@@ -36,37 +34,32 @@ export async function loader({
|
||||
}
|
||||
}
|
||||
|
||||
const [machine, { users }] = await Promise.all([
|
||||
context.client.get<{ node: Machine }>(
|
||||
`v1/node/${params.id}`,
|
||||
session.api_key,
|
||||
),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.api_key),
|
||||
]);
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||
const node = nodes.find((node) => node.id === params.id);
|
||||
|
||||
const lookup = await context.agents?.lookup([machine.node.nodeKey]);
|
||||
const [node] = mapNodes([machine.node], lookup);
|
||||
const lookup = await context.agents?.lookup([node.nodeKey]);
|
||||
const [enhancedNode] = mapNodes([node], lookup);
|
||||
const tags = Array.from(
|
||||
new Set([...node.validTags, ...node.forcedTags]),
|
||||
).sort();
|
||||
|
||||
return {
|
||||
node,
|
||||
node: enhancedNode,
|
||||
tags,
|
||||
users,
|
||||
magic,
|
||||
agent: context.agents?.agentID(),
|
||||
stats: lookup?.[node.nodeKey],
|
||||
stats: lookup?.[enhancedNode.nodeKey],
|
||||
existingTags: sortNodeTags(nodes),
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(request: ActionFunctionArgs) {
|
||||
return machineAction(request);
|
||||
}
|
||||
export const action = machineAction;
|
||||
|
||||
export default function Page() {
|
||||
const { node, tags, magic, users, agent, stats } =
|
||||
useLoaderData<typeof loader>();
|
||||
export default function Page({
|
||||
loaderData: { node, tags, users, magic, agent, stats, existingTags },
|
||||
}: Route.ComponentProps) {
|
||||
const [showRouting, setShowRouting] = useState(false);
|
||||
|
||||
const uiTags = useMemo(() => {
|
||||
@@ -93,7 +86,13 @@ export default function Page() {
|
||||
<h1 className="text-2xl font-medium">{node.givenName}</h1>
|
||||
<StatusCircle className="w-4 h-4" isOnline={node.online} />
|
||||
</span>
|
||||
<MenuOptions isFullButton magic={magic} node={node} users={users} />
|
||||
<MenuOptions
|
||||
existingTags={existingTags}
|
||||
isFullButton
|
||||
magic={magic}
|
||||
node={node}
|
||||
users={users}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 mb-4">
|
||||
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
|
||||
|
||||
@@ -1,22 +1,16 @@
|
||||
import { Info } from 'lucide-react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Tooltip from '~/components/Tooltip';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import { mapNodes } from '~/utils/node-info';
|
||||
import { mapNodes, sortNodeTags } from '~/utils/node-info';
|
||||
import type { Route } from './+types/overview';
|
||||
import MachineRow from './components/machine-row';
|
||||
import NewMachine from './dialogs/new';
|
||||
import { machineAction } from './machine-actions';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const user = session.user;
|
||||
if (!user) {
|
||||
@@ -40,10 +34,8 @@ export async function loader({
|
||||
Capabilities.write_machines,
|
||||
);
|
||||
|
||||
const [{ nodes }, { users }] = await Promise.all([
|
||||
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.api_key),
|
||||
]);
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||
|
||||
let magic: string | undefined;
|
||||
if (context.hs.readable()) {
|
||||
@@ -72,13 +64,9 @@ export async function loader({
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(request: ActionFunctionArgs) {
|
||||
return machineAction(request);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
export const action = machineAction;
|
||||
|
||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
@@ -95,10 +83,10 @@ export default function Page() {
|
||||
</p>
|
||||
</div>
|
||||
<NewMachine
|
||||
disabledKeys={data.preAuth ? [] : ['pre-auth']}
|
||||
isDisabled={!data.writable}
|
||||
server={data.publicServer ?? data.server}
|
||||
users={data.users}
|
||||
disabledKeys={loaderData.preAuth ? [] : ['pre-auth']}
|
||||
isDisabled={!loaderData.writable}
|
||||
server={loaderData.publicServer ?? loaderData.server}
|
||||
users={loaderData.users}
|
||||
/>
|
||||
</div>
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
@@ -108,7 +96,7 @@ export default function Page() {
|
||||
<th className="pb-2 w-1/4">
|
||||
<div className="flex items-center gap-x-1">
|
||||
<p className="uppercase text-xs font-bold">Addresses</p>
|
||||
{data.magic ? (
|
||||
{loaderData.magic ? (
|
||||
<Tooltip>
|
||||
<Info className="w-4 h-4" />
|
||||
<Tooltip.Body className="font-normal">
|
||||
@@ -116,7 +104,7 @@ export default function Page() {
|
||||
their name and also at{' '}
|
||||
<Code>
|
||||
[name].
|
||||
{data.magic}
|
||||
{loaderData.magic}
|
||||
</Code>
|
||||
</Tooltip.Body>
|
||||
</Tooltip>
|
||||
@@ -124,7 +112,7 @@ export default function Page() {
|
||||
</div>
|
||||
</th>
|
||||
{/* We only want to show the version column if there are agents */}
|
||||
{data.agent !== undefined ? (
|
||||
{loaderData.agent !== undefined ? (
|
||||
<th className="uppercase text-xs font-bold pb-2">Version</th>
|
||||
) : undefined}
|
||||
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
|
||||
@@ -136,18 +124,22 @@ export default function Page() {
|
||||
'border-t border-headplane-100 dark:border-headplane-800',
|
||||
)}
|
||||
>
|
||||
{data.populatedNodes.map((machine) => (
|
||||
{loaderData.populatedNodes.map((node) => (
|
||||
<MachineRow
|
||||
isAgent={data.agent ? data.agent === machine.nodeKey : undefined}
|
||||
isDisabled={
|
||||
data.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
: machine.user.providerId?.split('/').pop() !== data.subject
|
||||
existingTags={sortNodeTags(loaderData.nodes)}
|
||||
isAgent={
|
||||
loaderData.agent ? loaderData.agent === node.nodeKey : undefined
|
||||
}
|
||||
key={machine.id}
|
||||
magic={data.magic}
|
||||
node={machine}
|
||||
users={data.users}
|
||||
isDisabled={
|
||||
loaderData.writable
|
||||
? false // If the user has write permissions, they can edit all machines
|
||||
: node.user.providerId?.split('/').pop() !==
|
||||
loaderData.subject
|
||||
}
|
||||
key={node.id}
|
||||
magic={loaderData.magic}
|
||||
node={node}
|
||||
users={loaderData.users}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import { data } from 'react-router';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { PreAuthKey } from '~/types';
|
||||
import type { Route } from './+types/overview';
|
||||
|
||||
export async function authKeysAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
@@ -20,7 +16,7 @@ export async function authKeysAction({
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const apiKey = session.api_key;
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data('Missing `action_id` in the form data.', {
|
||||
@@ -29,90 +25,71 @@ export async function authKeysAction({
|
||||
}
|
||||
|
||||
switch (action) {
|
||||
case 'add_preauthkey':
|
||||
return await addPreAuthKey(formData, apiKey, context);
|
||||
case 'expire_preauthkey':
|
||||
return await expirePreAuthKey(formData, apiKey, context);
|
||||
case 'add_preauthkey': {
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
return data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const expiry = formData.get('expiry')?.toString();
|
||||
if (!expiry) {
|
||||
return data('Missing `expiry` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const reusable = formData.get('reusable')?.toString();
|
||||
if (!reusable) {
|
||||
return data('Missing `reusable` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const ephemeral = formData.get('ephemeral')?.toString();
|
||||
if (!ephemeral) {
|
||||
return data('Missing `ephemeral` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract the first "word" from expiry which is the day number
|
||||
// Calculate the date X days from now using the day number
|
||||
const day = Number(expiry.toString().split(' ')[0]);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + day);
|
||||
await api.createPreAuthKey(
|
||||
user,
|
||||
ephemeral === 'on',
|
||||
reusable === 'on',
|
||||
date,
|
||||
[], // TODO
|
||||
);
|
||||
|
||||
return data('Pre-auth key created');
|
||||
}
|
||||
case 'expire_preauthkey': {
|
||||
const key = formData.get('key')?.toString();
|
||||
if (!key) {
|
||||
return data('Missing `key` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
return data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.expirePreAuthKey(user, key);
|
||||
return data('Pre-auth key expired');
|
||||
}
|
||||
default:
|
||||
return data('Invalid action', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function addPreAuthKey(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
return data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const expiry = formData.get('expiry')?.toString();
|
||||
if (!expiry) {
|
||||
return data('Missing `expiry` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const reusable = formData.get('reusable')?.toString();
|
||||
if (!reusable) {
|
||||
return data('Missing `reusable` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const ephemeral = formData.get('ephemeral')?.toString();
|
||||
if (!ephemeral) {
|
||||
return data('Missing `ephemeral` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract the first "word" from expiry which is the day number
|
||||
// Calculate the date X days from now using the day number
|
||||
const day = Number(expiry.toString().split(' ')[0]);
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + day);
|
||||
|
||||
await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey',
|
||||
apiKey,
|
||||
{
|
||||
user,
|
||||
ephemeral: ephemeral === 'on',
|
||||
reusable: reusable === 'on',
|
||||
expiration: date.toISOString(),
|
||||
aclTags: [], // TODO
|
||||
},
|
||||
);
|
||||
|
||||
return data('Pre-auth key created');
|
||||
}
|
||||
|
||||
async function expirePreAuthKey(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const key = formData.get('key')?.toString();
|
||||
if (!key) {
|
||||
return data('Missing `key` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const user = formData.get('user_id')?.toString();
|
||||
if (!user) {
|
||||
return data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await context.client.post('v1/preauthkey/expire', apiKey, { user, key });
|
||||
return data('Pre-auth key expired');
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Key, useState } from 'react';
|
||||
import { useFetcher } from 'react-router';
|
||||
import Dialog from '~/components/Dialog';
|
||||
import Link from '~/components/Link';
|
||||
import NumberInput from '~/components/NumberInput';
|
||||
@@ -22,36 +21,38 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
|
||||
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Generate auth key</Dialog.Title>
|
||||
<input type="hidden" name="action_id" value="add_preauthkey" />
|
||||
<input type="hidden" name="user_id" value={userId?.toString()} />
|
||||
<input name="action_id" type="hidden" value="add_preauthkey" />
|
||||
<input name="user_id" type="hidden" value={userId?.toString()} />
|
||||
<Select
|
||||
className="mb-2"
|
||||
description="This is the user machines will belong to when they authenticate."
|
||||
isRequired
|
||||
label="User"
|
||||
name="user"
|
||||
placeholder="Select a user"
|
||||
description="This is the user machines will belong to when they authenticate."
|
||||
className="mb-2"
|
||||
onSelectionChange={(value) => {
|
||||
setUserId(value);
|
||||
}}
|
||||
placeholder="Select a user"
|
||||
>
|
||||
{data.users.map((user) => (
|
||||
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</Select.Item>
|
||||
))}
|
||||
</Select>
|
||||
<NumberInput
|
||||
isRequired
|
||||
name="expiry"
|
||||
label="Key Expiration"
|
||||
description="Set this key to expire after a certain number of days."
|
||||
minValue={1}
|
||||
maxValue={365_000} // 1000 years
|
||||
defaultValue={90}
|
||||
description="Set this key to expire after a certain number of days."
|
||||
formatOptions={{
|
||||
style: 'unit',
|
||||
unit: 'day',
|
||||
unitDisplay: 'short',
|
||||
}}
|
||||
isRequired
|
||||
label="Key Expiration"
|
||||
maxValue={365_000} // 1000 years
|
||||
minValue={1}
|
||||
name="expiry"
|
||||
/>
|
||||
<div className="flex justify-between items-center gap-2 mt-6">
|
||||
<div>
|
||||
@@ -61,15 +62,15 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
defaultSelected={reusable}
|
||||
label="Reusable"
|
||||
name="reusable"
|
||||
defaultSelected={reusable}
|
||||
onChange={() => {
|
||||
setReusable(!reusable);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="reusable" value={reusable.toString()} />
|
||||
<input name="reusable" type="hidden" value={reusable.toString()} />
|
||||
<div className="flex justify-between items-center gap-2 mt-6">
|
||||
<div>
|
||||
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
|
||||
@@ -77,23 +78,23 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
|
||||
Devices authenticated with this key will be automatically removed
|
||||
once they go offline.{' '}
|
||||
<Link
|
||||
to="https://tailscale.com/kb/1111/ephemeral-nodes"
|
||||
name="Tailscale Ephemeral Nodes Documentation"
|
||||
to="https://tailscale.com/kb/1111/ephemeral-nodes"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
</Dialog.Text>
|
||||
</div>
|
||||
<Switch
|
||||
defaultSelected={ephemeral}
|
||||
label="Ephemeral"
|
||||
name="ephemeral"
|
||||
defaultSelected={ephemeral}
|
||||
onChange={() => {
|
||||
setEphemeral(!ephemeral);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<input type="hidden" name="ephemeral" value={ephemeral.toString()} />
|
||||
<input name="ephemeral" type="hidden" value={ephemeral.toString()} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -12,9 +12,9 @@ export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
|
||||
<Dialog.Button variant="heavy">Expire Key</Dialog.Button>
|
||||
<Dialog.Panel variant="destructive">
|
||||
<Dialog.Title>Expire auth key?</Dialog.Title>
|
||||
<input type="hidden" name="action_id" value="expire_preauthkey" />
|
||||
<input type="hidden" name="user_id" value={user.id} />
|
||||
<input type="hidden" name="key" value={authKey.key} />
|
||||
<input name="action_id" type="hidden" value="expire_preauthkey" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<input name="key" type="hidden" value={authKey.key} />
|
||||
<Dialog.Text>
|
||||
Expiring this authentication key will immediately prevent it from
|
||||
being used to authenticate new devices. This action cannot be undone.
|
||||
|
||||
@@ -1,41 +1,29 @@
|
||||
import { FileKey2 } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { Link as RemixLink, useLoaderData } from 'react-router';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import Code from '~/components/Code';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import Select from '~/components/Select';
|
||||
import TableList from '~/components/TableList';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { PreAuthKey, User } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import type { Route } from './+types/overview';
|
||||
import { authKeysAction } from './actions';
|
||||
import AuthKeyRow from './auth-key-row';
|
||||
import AddAuthKey from './dialogs/add-auth-key';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.api_key,
|
||||
);
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
|
||||
const users = await api.getUsers();
|
||||
const preAuthKeys = await Promise.all(
|
||||
users
|
||||
.filter((user) => user.name?.length > 0) // Filter out any invalid users
|
||||
.map(async (user) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.set('user', user.id);
|
||||
|
||||
try {
|
||||
const { preAuthKeys } = await context.client.get<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>(`v1/preauthkey?${qp.toString()}`, session.api_key);
|
||||
const preAuthKeys = await api.getPreAuthKeys(user.id);
|
||||
return {
|
||||
success: true,
|
||||
user,
|
||||
@@ -47,7 +35,7 @@ export async function loader({
|
||||
success: false,
|
||||
user,
|
||||
error,
|
||||
preAuthKeys: [] as PreAuthKey[],
|
||||
preAuthKeys: [],
|
||||
};
|
||||
}
|
||||
}),
|
||||
@@ -79,13 +67,12 @@ export async function loader({
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(request: ActionFunctionArgs<LoadContext>) {
|
||||
return authKeysAction(request);
|
||||
}
|
||||
export const action = authKeysAction;
|
||||
|
||||
type Status = 'all' | 'active' | 'expired' | 'reusable' | 'ephemeral';
|
||||
export default function Page() {
|
||||
const { keys, missing, users, url, access } = useLoaderData<typeof loader>();
|
||||
export default function Page({
|
||||
loaderData: { keys, missing, users, url, access },
|
||||
}: Route.ComponentProps) {
|
||||
const [selectedUser, setSelectedUser] = useState('__headplane_all');
|
||||
const [status, setStatus] = useState<Status>('active');
|
||||
const isDisabled =
|
||||
@@ -198,7 +185,9 @@ export default function Page() {
|
||||
{[
|
||||
<Select.Item key="__headplane_all">All</Select.Item>,
|
||||
...keys.map(({ user }) => (
|
||||
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
|
||||
<Select.Item key={user.id}>
|
||||
{user.name || user.displayName || user.email || user.id}
|
||||
</Select.Item>
|
||||
)),
|
||||
]}
|
||||
</Select>
|
||||
|
||||
@@ -1,22 +1,18 @@
|
||||
import { ArrowRight } from 'lucide-react';
|
||||
import {
|
||||
LoaderFunctionArgs,
|
||||
Link as RemixLink,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import { Link as RemixLink } from 'react-router';
|
||||
import Link from '~/components/Link';
|
||||
import { LoadContext } from '~/server';
|
||||
import type { Route } from './+types/overview';
|
||||
|
||||
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ context }: Route.LoaderArgs) {
|
||||
return {
|
||||
config: context.hs.writable(),
|
||||
oidc: context.oidc,
|
||||
isOidcEnabled: context.oidcConnector?.isValid ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { config, oidc } = useLoaderData<typeof loader>();
|
||||
|
||||
export default function Page({
|
||||
loaderData: { config, isOidcEnabled },
|
||||
}: Route.ComponentProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-8 max-w-(--breakpoint-lg)">
|
||||
<div className="flex flex-col w-2/3">
|
||||
@@ -47,7 +43,7 @@ export default function Page() {
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</div>
|
||||
</RemixLink>
|
||||
{config && oidc ? (
|
||||
{config && isOidcEnabled ? (
|
||||
<>
|
||||
<div className="flex flex-col w-2/3">
|
||||
<h1 className="text-2xl font-medium mb-4">
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import { data } from 'react-router';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
|
||||
export async function restrictionAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
}: Route.ActionArgs) {
|
||||
const check = await context.sessions.check(
|
||||
request,
|
||||
Capabilities.configure_iam,
|
||||
@@ -31,29 +31,162 @@ export async function restrictionAction({
|
||||
});
|
||||
}
|
||||
|
||||
// We only need healthchecks which don't rely on an API key
|
||||
const api = context.hsApi.getRuntimeClient('fake-api-key');
|
||||
switch (action) {
|
||||
case 'add_domain': {
|
||||
return addDomain(formData, context);
|
||||
const domain = formData.get('domain')?.toString()?.trim();
|
||||
if (!domain) {
|
||||
throw data('No domain provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const domains = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
|
||||
];
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('Domain added successfully.');
|
||||
}
|
||||
|
||||
case 'remove_domain': {
|
||||
return removeDomain(formData, context);
|
||||
const domain = formData.get('domain')?.toString()?.trim();
|
||||
if (!domain) {
|
||||
throw data('No domain provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
|
||||
if (!storedDomains.includes(domain)) {
|
||||
// Domain not found in the list
|
||||
throw data(`Domain "${domain}" not found in allowed domains.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out the domain to remove it from the list
|
||||
const domains = storedDomains.filter((d: string) => d !== domain);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('Domain removed successfully.');
|
||||
}
|
||||
|
||||
case 'add_group': {
|
||||
return addGroup(formData, context);
|
||||
const group = formData.get('group')?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data('No group provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const groups = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
|
||||
];
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_groups',
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('Group added successfully.');
|
||||
}
|
||||
|
||||
case 'remove_group': {
|
||||
return removeGroup(formData, context);
|
||||
const group = formData.get('group')?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data('No group provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
|
||||
if (!storedGroups.includes(group)) {
|
||||
// Group not found in the list
|
||||
throw data(`Group "${group}" not found in allowed groups.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out the group to remove it from the list
|
||||
const groups = storedGroups.filter((d: string) => d !== group);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_groups',
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('Group removed successfully.');
|
||||
}
|
||||
|
||||
case 'add_user': {
|
||||
return addUser(formData, context);
|
||||
const user = formData.get('user')?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data('No user provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const users = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
|
||||
];
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_users',
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('User added successfully.');
|
||||
}
|
||||
|
||||
case 'remove_user': {
|
||||
return removeUser(formData, context);
|
||||
const user = formData.get('user')?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data('No user provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
|
||||
if (!storedUsers.includes(user)) {
|
||||
// User not found in the list
|
||||
throw data(`User "${user}" not found in allowed users.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out the user to remove it from the list
|
||||
const users = storedUsers.filter((d: string) => d !== user);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_users',
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
return data('User removed successfully.');
|
||||
}
|
||||
|
||||
default: {
|
||||
@@ -63,159 +196,3 @@ export async function restrictionAction({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function addDomain(formData: FormData, context: LoadContext) {
|
||||
const domain = formData.get('domain')?.toString()?.trim();
|
||||
if (!domain) {
|
||||
throw data('No domain provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const domains = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
|
||||
];
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('Domain added successfully.');
|
||||
}
|
||||
|
||||
async function removeDomain(formData: FormData, context: LoadContext) {
|
||||
const domain = formData.get('domain')?.toString()?.trim();
|
||||
if (!domain) {
|
||||
throw data('No domain provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
|
||||
if (!storedDomains.includes(domain)) {
|
||||
// Domain not found in the list
|
||||
throw data(`Domain "${domain}" not found in allowed domains.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out the domain to remove it from the list
|
||||
const domains = storedDomains.filter((d: string) => d !== domain);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_domains',
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('Domain removed successfully.');
|
||||
}
|
||||
|
||||
async function addUser(formData: FormData, context: LoadContext) {
|
||||
const user = formData.get('user')?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data('No user provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const users = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
|
||||
];
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_users',
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('User added successfully.');
|
||||
}
|
||||
|
||||
async function removeUser(formData: FormData, context: LoadContext) {
|
||||
const user = formData.get('user')?.toString()?.trim();
|
||||
if (!user) {
|
||||
throw data('No user provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
|
||||
if (!storedUsers.includes(user)) {
|
||||
// User not found in the list
|
||||
throw data(`User "${user}" not found in allowed users.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out the user to remove it from the list
|
||||
const users = storedUsers.filter((d: string) => d !== user);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_users',
|
||||
value: users,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('User removed successfully.');
|
||||
}
|
||||
|
||||
async function addGroup(formData: FormData, context: LoadContext) {
|
||||
const group = formData.get('group')?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data('No group provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const groups = [
|
||||
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
|
||||
];
|
||||
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_groups',
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('Group added successfully.');
|
||||
}
|
||||
|
||||
async function removeGroup(formData: FormData, context: LoadContext) {
|
||||
const group = formData.get('group')?.toString()?.trim();
|
||||
if (!group) {
|
||||
throw data('No group provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
|
||||
if (!storedGroups.includes(group)) {
|
||||
// Group not found in the list
|
||||
throw data(`Group "${group}" not found in allowed groups.`, {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// Filter out the group to remove it from the list
|
||||
const groups = storedGroups.filter((d: string) => d !== group);
|
||||
await context.hs.patch([
|
||||
{
|
||||
path: 'oidc.allowed_groups',
|
||||
value: groups,
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(context.client);
|
||||
return data('Group removed successfully.');
|
||||
}
|
||||
|
||||
@@ -39,19 +39,19 @@ export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
|
||||
Add this domain to a list of allowed email domains that can
|
||||
authenticate with Headscale via OIDC.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="add_domain" />
|
||||
<input name="action_id" type="hidden" value="add_domain" />
|
||||
<Input
|
||||
isRequired
|
||||
label="Domain"
|
||||
description={
|
||||
domain.trim().length > 0
|
||||
? `Matches users with <user>@${domain.trim()}`
|
||||
: 'Enter a domain to match users with their email addresses.'
|
||||
}
|
||||
placeholder="example.com"
|
||||
isInvalid={domain.trim().length === 0 || isInvalid}
|
||||
isRequired
|
||||
label="Domain"
|
||||
name="domain"
|
||||
onChange={setDomain}
|
||||
isInvalid={domain.trim().length === 0 || isInvalid}
|
||||
placeholder="example.com"
|
||||
/>
|
||||
{isInvalid && (
|
||||
<p className="text-red-500 text-sm mt-2">
|
||||
|
||||
@@ -30,15 +30,15 @@ export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
|
||||
Add this group to a list of allowed groups that can authenticate with
|
||||
Headscale via OIDC.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="add_group" />
|
||||
<input name="action_id" type="hidden" value="add_group" />
|
||||
<Input
|
||||
description="The group to allow for OIDC authentication."
|
||||
isInvalid={group.trim().length === 0 || isInvalid}
|
||||
isRequired
|
||||
label="Group"
|
||||
description="The group to allow for OIDC authentication."
|
||||
placeholder="admin"
|
||||
name="group"
|
||||
onChange={setGroup}
|
||||
isInvalid={group.trim().length === 0 || isInvalid}
|
||||
placeholder="admin"
|
||||
/>
|
||||
{isInvalid && (
|
||||
<p className="text-red-500 text-sm mt-2">
|
||||
|
||||
@@ -30,15 +30,15 @@ export default function AddUser({ users, isDisabled }: AddUserProps) {
|
||||
Add this user to a list of allowed users that can authenticate with
|
||||
Headscale via OIDC.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="add_user" />
|
||||
<input name="action_id" type="hidden" value="add_user" />
|
||||
<Input
|
||||
description="The user to allow for OIDC authentication."
|
||||
isInvalid={user.trim().length === 0 || isInvalid}
|
||||
isRequired
|
||||
label="User"
|
||||
description="The user to allow for OIDC authentication."
|
||||
placeholder="john_doe"
|
||||
name="user"
|
||||
onChange={setUser}
|
||||
isInvalid={user.trim().length === 0 || isInvalid}
|
||||
placeholder="john_doe"
|
||||
/>
|
||||
{isInvalid && (
|
||||
<p className="text-red-500 text-sm mt-2">
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
LoaderFunctionArgs,
|
||||
Link as RemixLink,
|
||||
data,
|
||||
useLoaderData,
|
||||
} from 'react-router';
|
||||
import { data, Link as RemixLink } from 'react-router';
|
||||
import Link from '~/components/Link';
|
||||
import Notice from '~/components/Notice';
|
||||
import { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import type { Route } from './+types/overview';
|
||||
import { restrictionAction } from './actions';
|
||||
import AddDomain from './dialogs/add-domain';
|
||||
import AddGroup from './dialogs/add-group';
|
||||
import AddUser from './dialogs/add-user';
|
||||
import RestrictionTable from './table';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const check = await context.sessions.check(request, Capabilities.read_users);
|
||||
if (!check) {
|
||||
throw data('You do not have permission to view IAM settings.', {
|
||||
@@ -43,19 +34,18 @@ export async function loader({
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(request: ActionFunctionArgs) {
|
||||
return restrictionAction(request);
|
||||
}
|
||||
export const action = restrictionAction;
|
||||
|
||||
export default function Page() {
|
||||
const { access, writable, settings } = useLoaderData<typeof loader>();
|
||||
export default function Page({
|
||||
loaderData: { access, writable, settings },
|
||||
}: Route.ComponentProps) {
|
||||
const isDisabled = writable ? !access : true;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
|
||||
<div className="flex flex-col w-2/3">
|
||||
<p className="mb-4 text-md">
|
||||
<RemixLink to="/settings" className="font-medium">
|
||||
<RemixLink className="font-medium" to="/settings">
|
||||
Settings
|
||||
</RemixLink>
|
||||
<span className="mx-2">/</span> Authentication Restrictions
|
||||
@@ -85,33 +75,33 @@ export default function Page() {
|
||||
used to limit access to your Tailnet to only certain users or groups
|
||||
and Headplane will also respect these settings when authenticating.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||
name="Headscale OIDC documentation"
|
||||
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
<RestrictionTable
|
||||
isDisabled={isDisabled}
|
||||
type="domain"
|
||||
values={settings.domains}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
<RestrictionTable
|
||||
isDisabled={isDisabled}
|
||||
type="group"
|
||||
values={settings.groups}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
|
||||
</RestrictionTable>
|
||||
<RestrictionTable
|
||||
isDisabled={isDisabled}
|
||||
type="user"
|
||||
values={settings.users}
|
||||
isDisabled={isDisabled}
|
||||
>
|
||||
<AddUser users={settings.users} isDisabled={isDisabled} />
|
||||
<AddUser isDisabled={isDisabled} users={settings.users} />
|
||||
</RestrictionTable>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -40,18 +40,18 @@ export default function RestrictionTable({
|
||||
)}
|
||||
<Form method="POST">
|
||||
<input
|
||||
type="hidden"
|
||||
name="action_id"
|
||||
type="hidden"
|
||||
value={`remove_${type}`}
|
||||
/>
|
||||
<input type="hidden" name={type} value={value} />
|
||||
<input name={type} type="hidden" value={value} />
|
||||
<Button
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
className={cn(
|
||||
'px-2 py-1 rounded-md',
|
||||
'text-red-500 dark:text-red-400',
|
||||
)}
|
||||
isDisabled={isDisabled}
|
||||
type="submit"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
|
||||
+94
-82
@@ -1,22 +1,14 @@
|
||||
/** biome-ignore-all lint/correctness/noNestedComponentDefinitions: Wtf? */
|
||||
|
||||
import { faker } from '@faker-js/faker';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
ActionFunctionArgs,
|
||||
data,
|
||||
LoaderFunctionArgs,
|
||||
ShouldRevalidateFunction,
|
||||
useLoaderData,
|
||||
useSubmit,
|
||||
} from 'react-router';
|
||||
import wasm from '~/hp_ssh.wasm?url';
|
||||
import { LoadContext } from '~/server';
|
||||
import { data, type ShouldRevalidateFunction, useSubmit } from 'react-router';
|
||||
import { ExternalScriptsHandle } from 'remix-utils/external-scripts';
|
||||
import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
|
||||
import { Machine, PreAuthKey, User } from '~/types';
|
||||
import { useLiveData } from '~/utils/live-data';
|
||||
import '~/wasm_exec';
|
||||
import type { Route } from './+types/console';
|
||||
import UserPrompt from './user-prompt';
|
||||
import XTerm from './xterm.client';
|
||||
|
||||
@@ -24,10 +16,20 @@ export const shouldRevalidate: ShouldRevalidateFunction = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const origin = new URL(request.url).origin;
|
||||
const assets = ['/wasm_exec.js', '/hp_ssh.wasm'];
|
||||
const missing: string[] = [];
|
||||
|
||||
for (const file of assets) {
|
||||
const res = await fetch(`${origin}${file}`, { method: 'HEAD' });
|
||||
if (!res.ok) missing.push(file);
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
throw data('WebSSH is not configured in this build.', 405);
|
||||
}
|
||||
|
||||
if (!context.agents?.agentID()) {
|
||||
throw data(
|
||||
'WebSSH is only available with the Headplane agent integration',
|
||||
@@ -39,10 +41,9 @@ export async function loader({
|
||||
if (session.user.subject === 'unknown-non-oauth') {
|
||||
throw data('Only OAuth users are allowed to use WebSSH', 403);
|
||||
}
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
session.api_key,
|
||||
);
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const users = await api.getUsers();
|
||||
|
||||
// MARK: This assumes that a user has authenticated with Headscale first
|
||||
// Since the only way to enforce permissions via ACLs is to generate a
|
||||
@@ -63,15 +64,12 @@ export async function loader({
|
||||
);
|
||||
}
|
||||
|
||||
const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
|
||||
'v1/preauthkey',
|
||||
session.api_key,
|
||||
{
|
||||
user: lookup.id,
|
||||
reusable: false,
|
||||
ephemeral: true,
|
||||
expiration: new Date(Date.now() + 60 * 1000).toISOString(), // 1 minute
|
||||
},
|
||||
const preAuthKey = await api.createPreAuthKey(
|
||||
lookup.id,
|
||||
true, // ephemeral
|
||||
false, // reusable
|
||||
new Date(Date.now() + 60 * 1000), // expiration: 1 minute
|
||||
null, // aclTags
|
||||
);
|
||||
|
||||
// TODO: Enable config to enforce generate_authkeys capability
|
||||
@@ -116,13 +114,8 @@ export async function loader({
|
||||
// );
|
||||
// }
|
||||
|
||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.api_key,
|
||||
);
|
||||
|
||||
// node.name is the hostname, given_name is the set name
|
||||
const lookupNode = nodes.find((n) => n.name === hostname);
|
||||
const nodes = await api.getNodes();
|
||||
const lookupNode = nodes.find((n) => n.givenName === hostname);
|
||||
if (!lookupNode) {
|
||||
throw data(`Node with hostname ${hostname} not found`, 404);
|
||||
}
|
||||
@@ -168,11 +161,8 @@ function generateHostname(username: string) {
|
||||
return `ssh-${adjective}-${noun}-${username}`;
|
||||
}
|
||||
|
||||
export async function action({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
const _session = await context.sessions.auth(request);
|
||||
export async function action({ request, context }: Route.ActionArgs) {
|
||||
await context.sessions.auth(request);
|
||||
if (!context.agents?.agentID()) {
|
||||
throw data(
|
||||
'WebSSH is only available with the Headplane agent integration',
|
||||
@@ -200,13 +190,34 @@ export async function action({
|
||||
.where(eq(ephemeralNodes.auth_key, authKey));
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{
|
||||
rel: 'preload',
|
||||
href: '/hp_ssh.wasm',
|
||||
as: 'fetch',
|
||||
type: 'application/wasm',
|
||||
crossOrigin: 'anonymous',
|
||||
},
|
||||
];
|
||||
|
||||
export const handle: ExternalScriptsHandle = {
|
||||
scripts: [
|
||||
{
|
||||
src: '/wasm_exec.js',
|
||||
crossOrigin: 'anonymous',
|
||||
preload: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function Page({
|
||||
loaderData: { ipnDetails, sshDetails },
|
||||
}: Route.ComponentProps) {
|
||||
const submit = useSubmit();
|
||||
const { pause } = useLiveData();
|
||||
|
||||
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
||||
const [nodeKey, setNodeKey] = useState<string | null>(null);
|
||||
const { ipnDetails, sshDetails } = useLoaderData<typeof loader>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!ipnDetails) {
|
||||
@@ -215,46 +226,47 @@ export default function Page() {
|
||||
|
||||
pause();
|
||||
const go = new Go(); // Go is defined by wasm_exec.js
|
||||
WebAssembly.instantiateStreaming(fetch(wasm), go.importObject).then(
|
||||
(value) => {
|
||||
go.run(value.instance);
|
||||
const handle = TsWasmNet(ipnDetails, {
|
||||
NotifyState: (state) => {
|
||||
console.log('State changed:', state);
|
||||
if (state === 'Running') {
|
||||
setIpn(handle);
|
||||
}
|
||||
},
|
||||
NotifyNetMap: (netmap) => {
|
||||
// Only set NodeKey if it is not already set and then
|
||||
// also dispatch that to the backend to track the
|
||||
// ephemeral node.
|
||||
//
|
||||
// We open an SSE connection to the backend
|
||||
// so that when the connection is closed,
|
||||
// the backend can delete the ephemeral node.
|
||||
if (nodeKey === null) {
|
||||
setNodeKey(netmap.NodeKey);
|
||||
submit(
|
||||
{
|
||||
node_key: netmap.NodeKey,
|
||||
auth_key: ipnDetails.PreAuthKey,
|
||||
},
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
},
|
||||
NotifyBrowseToURL: (url) => {
|
||||
console.log('Browse to URL:', url);
|
||||
},
|
||||
NotifyPanicRecover: (message) => {
|
||||
console.error('Panic recover:', message);
|
||||
},
|
||||
});
|
||||
WebAssembly.instantiateStreaming(
|
||||
fetch('/hp_ssh.wasm'),
|
||||
go.importObject,
|
||||
).then((value) => {
|
||||
go.run(value.instance);
|
||||
const handle = TsWasmNet(ipnDetails, {
|
||||
NotifyState: (state) => {
|
||||
console.log('State changed:', state);
|
||||
if (state === 'Running') {
|
||||
setIpn(handle);
|
||||
}
|
||||
},
|
||||
NotifyNetMap: (netmap) => {
|
||||
// Only set NodeKey if it is not already set and then
|
||||
// also dispatch that to the backend to track the
|
||||
// ephemeral node.
|
||||
//
|
||||
// We open an SSE connection to the backend
|
||||
// so that when the connection is closed,
|
||||
// the backend can delete the ephemeral node.
|
||||
if (nodeKey === null) {
|
||||
setNodeKey(netmap.NodeKey);
|
||||
submit(
|
||||
{
|
||||
node_key: netmap.NodeKey,
|
||||
auth_key: ipnDetails.PreAuthKey,
|
||||
},
|
||||
{ method: 'POST' },
|
||||
);
|
||||
}
|
||||
},
|
||||
NotifyBrowseToURL: (url) => {
|
||||
console.log('Browse to URL:', url);
|
||||
},
|
||||
NotifyPanicRecover: (message) => {
|
||||
console.error('Panic recover:', message);
|
||||
},
|
||||
});
|
||||
|
||||
handle.Start();
|
||||
},
|
||||
);
|
||||
handle.Start();
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (!sshDetails.username) {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Building2, House, Key } from 'lucide-react';
|
||||
import Card from '~/components/Card';
|
||||
import Link from '~/components/Link';
|
||||
import type { HeadplaneConfig } from '~/server/config/schema';
|
||||
import CreateUser from '../dialogs/create-user';
|
||||
|
||||
interface ManageBannerProps {
|
||||
oidc?: NonNullable<HeadplaneConfig['oidc']>;
|
||||
oidc?: { issuer: string };
|
||||
isDisabled?: boolean;
|
||||
}
|
||||
|
||||
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
|
||||
return (
|
||||
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
|
||||
<Card className="mb-8 w-full max-w-full p-0" variant="flat">
|
||||
<div className="flex flex-col md:flex-row">
|
||||
<div className="w-full p-4 border-b md:border-b-0 border-headplane-100 dark:border-headplane-800">
|
||||
{oidc ? (
|
||||
@@ -26,14 +25,14 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
|
||||
{oidc ? (
|
||||
<>
|
||||
Users are managed through your{' '}
|
||||
<Link to={oidc.issuer} name="OIDC Provider">
|
||||
<Link name="OIDC Provider" to={oidc.issuer}>
|
||||
OpenID Connect provider
|
||||
</Link>
|
||||
{'. '}
|
||||
Groups and user information do not automatically sync.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/oidc"
|
||||
name="Headscale OIDC Documentation"
|
||||
to="https://headscale.net/stable/ref/oidc"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
@@ -43,8 +42,8 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
|
||||
Users are not managed externally. Using OpenID Connect can
|
||||
create a better experience when using Headscale.{' '}
|
||||
<Link
|
||||
to="https://headscale.net/stable/ref/oidc"
|
||||
name="Headscale OIDC Documentation"
|
||||
to="https://headscale.net/stable/ref/oidc"
|
||||
>
|
||||
Learn more
|
||||
</Link>
|
||||
@@ -61,7 +60,7 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
|
||||
: 'You can add, remove, and rename users here.'}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<CreateUser isOidc={oidc !== undefined} isDisabled={isDisabled} />
|
||||
<CreateUser isDisabled={isDisabled} isOidc={oidc !== undefined} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -22,40 +22,40 @@ export default function UserMenu({ user }: MenuProps) {
|
||||
<>
|
||||
{modal === 'rename' && (
|
||||
<Rename
|
||||
user={user}
|
||||
isOpen={modal === 'rename'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === 'delete' && (
|
||||
<Delete
|
||||
user={user}
|
||||
isOpen={modal === 'delete'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === 'reassign' && (
|
||||
<Reassign
|
||||
user={user}
|
||||
isOpen={modal === 'reassign'}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : ['reassign']}>
|
||||
<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',
|
||||
)}
|
||||
label="Machine Options"
|
||||
>
|
||||
<Ellipsis className="h-5" />
|
||||
</Menu.IconButton>
|
||||
|
||||
@@ -18,22 +18,24 @@ export default function UserRow({ user, role }: UserRowProps) {
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={user.id}
|
||||
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
|
||||
key={user.id}
|
||||
>
|
||||
<td className="pl-0.5 py-2">
|
||||
<div className="flex items-center">
|
||||
{user.profilePicUrl ? (
|
||||
<img
|
||||
src={user.profilePicUrl}
|
||||
alt={user.name || user.displayName}
|
||||
className="w-10 h-10 rounded-full"
|
||||
src={user.profilePicUrl}
|
||||
/>
|
||||
) : (
|
||||
<CircleUser className="w-10 h-10" />
|
||||
)}
|
||||
<div className="ml-4">
|
||||
<p className={cn('font-semibold leading-snug')}>{user.name || user.displayName}</p>
|
||||
<p className={cn('font-semibold leading-snug')}>
|
||||
{user.name || user.displayName}
|
||||
</p>
|
||||
<p className="text-sm opacity-50">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -43,8 +45,8 @@ export default function UserRow({ user, role }: UserRowProps) {
|
||||
</td>
|
||||
<td className="pl-0.5 py-2">
|
||||
<p
|
||||
suppressHydrationWarning
|
||||
className="text-sm text-headplane-600 dark:text-headplane-300"
|
||||
suppressHydrationWarning
|
||||
>
|
||||
{new Date(user.createdAt).toLocaleDateString()}
|
||||
</p>
|
||||
@@ -56,7 +58,7 @@ export default function UserRow({ user, role }: UserRowProps) {
|
||||
'text-headplane-600 dark:text-headplane-300',
|
||||
)}
|
||||
>
|
||||
<StatusCircle isOnline={isOnline} className="w-4 h-4" />
|
||||
<StatusCircle className="w-4 h-4" isOnline={isOnline} />
|
||||
<p suppressHydrationWarning>
|
||||
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
|
||||
</p>
|
||||
|
||||
@@ -24,15 +24,14 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
|
||||
</>
|
||||
) : undefined}
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="create_user" />
|
||||
<input name="action_id" type="hidden" value="create_user" />
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
isRequired
|
||||
name="username"
|
||||
type="text"
|
||||
label="Username"
|
||||
name="username"
|
||||
placeholder="my-new-user"
|
||||
validationBehavior="native"
|
||||
type="text"
|
||||
validate={(value) => {
|
||||
if (value.trim().length === 0) {
|
||||
return 'Username is required';
|
||||
@@ -44,19 +43,20 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
|
||||
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
name="display_name"
|
||||
type="text"
|
||||
label="Display Name"
|
||||
placeholder="John Doe"
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Input
|
||||
name="email"
|
||||
type="email"
|
||||
label="Display Name"
|
||||
name="display_name"
|
||||
placeholder="John Doe"
|
||||
type="text"
|
||||
validationBehavior="native"
|
||||
/>
|
||||
<Input
|
||||
label="Email"
|
||||
name="email"
|
||||
placeholder="name@example.com"
|
||||
type="email"
|
||||
validationBehavior="native"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -32,8 +32,8 @@ export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
|
||||
)}
|
||||
</Dialog.Text>
|
||||
)}
|
||||
<input type="hidden" name="action_id" value="delete_user" />
|
||||
<input type="hidden" name="user_id" value={user.id} />
|
||||
<input name="action_id" type="hidden" value="delete_user" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -21,14 +21,16 @@ export default function ReassignUser({
|
||||
<Dialog.Panel
|
||||
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
|
||||
>
|
||||
<Dialog.Title>Change role for {user.name || user.displayName}?</Dialog.Title>
|
||||
<Dialog.Title>
|
||||
Change role for {user.name || user.displayName}?
|
||||
</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
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"
|
||||
to="https://tailscale.com/kb/1138/user-roles"
|
||||
>
|
||||
Learn More
|
||||
</Link>
|
||||
@@ -37,21 +39,21 @@ export default function ReassignUser({
|
||||
<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} />
|
||||
<input name="action_id" type="hidden" value="reassign_user" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<RadioGroup
|
||||
isRequired
|
||||
name="new_role"
|
||||
label="Role"
|
||||
className="gap-4"
|
||||
defaultValue={user.headplaneRole}
|
||||
isRequired
|
||||
label="Role"
|
||||
name="new_role"
|
||||
>
|
||||
{Object.keys(Roles)
|
||||
.filter((role) => role !== 'owner')
|
||||
.map((role) => {
|
||||
const { name, desc } = mapRoleToName(role);
|
||||
return (
|
||||
<RadioGroup.Radio key={role} value={role} label={name}>
|
||||
<RadioGroup.Radio key={role} label={name} value={role}>
|
||||
<div className="block">
|
||||
<p className="font-bold">{name}</p>
|
||||
<p className="opacity-70">{desc}</p>
|
||||
|
||||
@@ -15,18 +15,18 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
|
||||
<Dialog.Panel>
|
||||
<Dialog.Title>Rename {user.name || user.displayName}?</Dialog.Title>
|
||||
<Dialog.Text className="mb-6">
|
||||
Enter a new username for {user.name || user.displayName}. Changing a username will not
|
||||
update any ACL policies that may refer to this user by their old
|
||||
username.
|
||||
Enter a new username for {user.name || user.displayName}. Changing a
|
||||
username will not update any ACL policies that may refer to this user
|
||||
by their old username.
|
||||
</Dialog.Text>
|
||||
<input type="hidden" name="action_id" value="rename_user" />
|
||||
<input type="hidden" name="user_id" value={user.id} />
|
||||
<input name="action_id" type="hidden" value="rename_user" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<Input
|
||||
isRequired
|
||||
name="new_name"
|
||||
label="Username"
|
||||
placeholder="my-new-name"
|
||||
defaultValue={user.name}
|
||||
isRequired
|
||||
label="Username"
|
||||
name="new_name"
|
||||
placeholder="my-new-name"
|
||||
/>
|
||||
</Dialog.Panel>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { LoaderFunctionArgs, redirect } from 'react-router';
|
||||
import { LoadContext } from '~/server';
|
||||
import { redirect } from 'react-router';
|
||||
import { users } from '~/server/db/schema';
|
||||
import type { Route } from './+types/onboarding-skip';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
const { user } = await context.sessions.auth(request);
|
||||
await context.db
|
||||
|
||||
@@ -1,26 +1,20 @@
|
||||
import { Icon } from '@iconify/react';
|
||||
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, useLoaderData } from 'react-router';
|
||||
import { NavLink } from 'react-router';
|
||||
import Button from '~/components/Button';
|
||||
import Card from '~/components/Card';
|
||||
import Link from '~/components/Link';
|
||||
import Options from '~/components/Options';
|
||||
import StatusCircle from '~/components/StatusCircle';
|
||||
import { 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';
|
||||
import type { Route } from './+types/onboarding';
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
|
||||
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
|
||||
@@ -51,13 +45,10 @@ export async function loader({
|
||||
break;
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
let firstMachine: Machine | undefined;
|
||||
try {
|
||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.api_key,
|
||||
);
|
||||
|
||||
const nodes = await api.getNodes();
|
||||
const node = nodes.find((n) => {
|
||||
if (n.user.provider !== 'oidc') {
|
||||
return false;
|
||||
@@ -90,8 +81,9 @@ export async function loader({
|
||||
};
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const { user, osValue, firstMachine } = useLoaderData<typeof loader>();
|
||||
export default function Page({
|
||||
loaderData: { user, osValue, firstMachine },
|
||||
}: Route.ComponentProps) {
|
||||
const { pause, resume } = useLiveData();
|
||||
useEffect(() => {
|
||||
if (firstMachine) {
|
||||
@@ -132,7 +124,7 @@ export default function Page() {
|
||||
key="linux"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<PiTerminalFill className="ml-1 w-4" />
|
||||
<Icon className="ml-1 w-4" icon="ion:terminal" />
|
||||
<span>Linux</span>
|
||||
</div>
|
||||
}
|
||||
@@ -163,7 +155,7 @@ export default function Page() {
|
||||
key="windows"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<PiWindowsLogoFill className="ml-1 w-4" />
|
||||
<Icon className="ml-1 w-4" icon="mdi:microsoft" />
|
||||
<span>Windows</span>
|
||||
</div>
|
||||
}
|
||||
@@ -186,7 +178,10 @@ export default function Page() {
|
||||
key="macos"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<ImFinder className="ml-1 w-4" />
|
||||
<Icon
|
||||
className="ml-1 w-4"
|
||||
icon="streamline-logos:mac-finder-logo-solid"
|
||||
/>
|
||||
<span>macOS</span>
|
||||
</div>
|
||||
}
|
||||
@@ -218,7 +213,7 @@ export default function Page() {
|
||||
key="ios"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<GrApple className="ml-1 w-4" />
|
||||
<Icon className="ml-1 w-4" icon="grommet-icons:apple" />
|
||||
<span>iOS</span>
|
||||
</div>
|
||||
}
|
||||
@@ -241,7 +236,7 @@ export default function Page() {
|
||||
key="android"
|
||||
title={
|
||||
<div className="flex items-center gap-1">
|
||||
<MdAndroid className="ml-1 w-4" />
|
||||
<Icon className="ml-1 w-4" icon="material-symbols:android" />
|
||||
<span>Android</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { Capabilities } from '~/server/web/roles';
|
||||
import { Machine, User } from '~/types';
|
||||
import type { Machine, User } from '~/types';
|
||||
import cn from '~/utils/cn';
|
||||
import type { Route } from './+types/overview';
|
||||
import ManageBanner from './components/manage-banner';
|
||||
import UserRow from './components/user-row';
|
||||
import { userAction } from './user-actions';
|
||||
@@ -13,10 +11,7 @@ interface UserMachine extends User {
|
||||
machines: Machine[];
|
||||
}
|
||||
|
||||
export async function loader({
|
||||
request,
|
||||
context,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(request, Capabilities.read_users);
|
||||
if (!check) {
|
||||
@@ -31,14 +26,12 @@ export async function loader({
|
||||
Capabilities.write_users,
|
||||
);
|
||||
|
||||
const [machines, apiUsers] = await Promise.all([
|
||||
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
|
||||
context.client.get<{ users: User[] }>('v1/user', session.api_key),
|
||||
]);
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
|
||||
|
||||
const users = apiUsers.users.map((user) => ({
|
||||
const users = apiUsers.map((user) => ({
|
||||
...user,
|
||||
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
|
||||
machines: nodes.filter((node) => node.user.id === user.id),
|
||||
}));
|
||||
|
||||
const roles = await Promise.all(
|
||||
@@ -77,28 +70,29 @@ export async function loader({
|
||||
|
||||
return {
|
||||
writable: writablePermission, // whether the user can write to the API
|
||||
oidc: context.config.oidc,
|
||||
oidc: context.config.oidc
|
||||
? {
|
||||
issuer: context.config.oidc.issuer,
|
||||
}
|
||||
: undefined,
|
||||
roles,
|
||||
magic,
|
||||
users,
|
||||
};
|
||||
}
|
||||
|
||||
export async function action(data: ActionFunctionArgs) {
|
||||
return userAction(data);
|
||||
}
|
||||
export const action = userAction;
|
||||
|
||||
export default function Page() {
|
||||
const data = useLoaderData<typeof loader>();
|
||||
const [users, setUsers] = useState<UserMachine[]>(data.users);
|
||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
const [users, setUsers] = useState<UserMachine[]>(loaderData.users);
|
||||
|
||||
// This useEffect is entirely for the purpose of updating the users when the
|
||||
// drag and drop changes the machines between users. It's pretty hacky, but
|
||||
// the idea is to treat data.users as the source of truth and update the
|
||||
// local state when it changes.
|
||||
useEffect(() => {
|
||||
setUsers(data.users);
|
||||
}, [data.users]);
|
||||
setUsers(loaderData.users);
|
||||
}, [loaderData.users]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -106,7 +100,7 @@ export default function Page() {
|
||||
<p className="mb-8 text-md">
|
||||
Manage the users in your network and their permissions.
|
||||
</p>
|
||||
<ManageBanner isDisabled={!data.writable} oidc={data.oidc} />
|
||||
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
|
||||
<table className="table-auto w-full rounded-lg">
|
||||
<thead className="text-headplane-600 dark:text-headplane-300">
|
||||
<tr className="text-left px-0.5">
|
||||
@@ -127,7 +121,7 @@ export default function Page() {
|
||||
.map((user) => (
|
||||
<UserRow
|
||||
key={user.id}
|
||||
role={data.roles[users.indexOf(user)]}
|
||||
role={loaderData.roles[users.indexOf(user)]}
|
||||
user={user}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -1,140 +1,115 @@
|
||||
import { ActionFunctionArgs, data } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import { data } from 'react-router';
|
||||
import { Capabilities, Roles } from '~/server/web/roles';
|
||||
import { User } from '~/types';
|
||||
import { data400, data403 } from '~/utils/res';
|
||||
import type { Route } from './+types/overview';
|
||||
|
||||
export async function userAction({
|
||||
request,
|
||||
context,
|
||||
}: ActionFunctionArgs<LoadContext>) {
|
||||
export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const check = await context.sessions.check(request, Capabilities.write_users);
|
||||
if (!check) {
|
||||
throw data403('You do not have permission to update users');
|
||||
throw data('You do not have permission to update users', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = session.api_key;
|
||||
const formData = await request.formData();
|
||||
const action = formData.get('action_id')?.toString();
|
||||
if (!action) {
|
||||
throw data400('Missing `action_id` in the form data.');
|
||||
throw data('Missing `action_id` in the form data.', {
|
||||
status: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
switch (action) {
|
||||
case 'create_user':
|
||||
return createUser(formData, apiKey, context);
|
||||
case 'delete_user':
|
||||
return deleteUser(formData, apiKey, context);
|
||||
case 'rename_user':
|
||||
return renameUser(formData, apiKey, context);
|
||||
case 'reassign_user':
|
||||
return reassignUser(formData, apiKey, context);
|
||||
case 'create_user': {
|
||||
const name = formData.get('username')?.toString();
|
||||
const displayName = formData.get('display_name')?.toString();
|
||||
const email = formData.get('email')?.toString();
|
||||
|
||||
if (!name) {
|
||||
throw data('Missing `username` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.createUser(name, email, displayName);
|
||||
return { message: 'User created successfully' };
|
||||
}
|
||||
case 'delete_user': {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
if (!userId) {
|
||||
throw data('Missing `user_id` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.deleteUser(userId);
|
||||
return { message: 'User deleted successfully' };
|
||||
}
|
||||
case 'rename_user': {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
const newName = formData.get('new_name')?.toString();
|
||||
if (!userId || !newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data(`No user found with id: ${userId}`, { status: 400 });
|
||||
}
|
||||
|
||||
if (user.provider === 'oidc') {
|
||||
// OIDC users cannot be renamed via this endpoint, return an error
|
||||
throw data('Users managed by OIDC cannot be renamed', {
|
||||
status: 403,
|
||||
});
|
||||
}
|
||||
|
||||
await api.renameUser(userId, newName);
|
||||
return { message: 'User renamed successfully' };
|
||||
}
|
||||
case 'reassign_user': {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
const newRole = formData.get('new_role')?.toString();
|
||||
if (!userId || !newRole) {
|
||||
throw data('Missing `user_id` or `new_role` in the form data.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user?.providerId) {
|
||||
throw data('Specified user is not an OIDC user', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
// For some reason, headscale makes providerID a url where the
|
||||
// last component is the subject, so we need to strip that out
|
||||
const subject = user.providerId?.split('/').pop();
|
||||
if (!subject) {
|
||||
throw data(
|
||||
'Malformed `providerId` for the specified user. Cannot find subject.',
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const result = await context.sessions.reassignSubject(
|
||||
subject,
|
||||
newRole as keyof typeof Roles,
|
||||
);
|
||||
|
||||
if (!result) {
|
||||
throw data('Failed to reassign user role.', { status: 500 });
|
||||
}
|
||||
|
||||
return { message: 'User reassigned successfully' };
|
||||
}
|
||||
default:
|
||||
throw data400('Invalid `action_id` provided.');
|
||||
throw data('Invalid `action_id` provided.', {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
throw data400('Missing `username` in the form data.');
|
||||
}
|
||||
|
||||
await context.client.post('v1/user', apiKey, {
|
||||
name,
|
||||
displayName,
|
||||
email,
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteUser(
|
||||
formData: FormData,
|
||||
apiKey: string,
|
||||
context: LoadContext,
|
||||
) {
|
||||
const userId = formData.get('user_id')?.toString();
|
||||
if (!userId) {
|
||||
throw data400('Missing `user_id` in the form data.');
|
||||
}
|
||||
|
||||
await context.client.delete(`v1/user/${userId}`, apiKey);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
const { users } = await context.client.get<{ users: User[] }>(
|
||||
'v1/user',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data400(`No user found with id: ${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,8 +1,10 @@
|
||||
import { LoaderFunctionArgs } from 'react-router';
|
||||
import type { LoadContext } from '~/server';
|
||||
import type { Route } from './+types/healthz';
|
||||
|
||||
export async function loader({ context }: Route.LoaderArgs) {
|
||||
// Use a fake API key for healthcheck
|
||||
const api = context.hsApi.getRuntimeClient('fake-api-key');
|
||||
const healthy = await api.isHealthy();
|
||||
|
||||
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
|
||||
const healthy = await context.client.healthcheck();
|
||||
return new Response(JSON.stringify({ status: healthy ? 'OK' : 'ERROR' }), {
|
||||
status: healthy ? 200 : 500,
|
||||
headers: {
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Headplane Server
|
||||
This code is responsible for all code that is necessary *before* any
|
||||
web server is started. It is the only part of the code that contains
|
||||
many side-effects (in this case, importing a module may run code).
|
||||
|
||||
# Hierarchy
|
||||
```
|
||||
server
|
||||
├── index.ts: Loads everything and starts the web server.
|
||||
├── agent/
|
||||
│ ├── dispatcher.ts: Serializes commands for the agent control fd (stdin).
|
||||
│ ├── ssh.ts: Manages & multiplexes the active web SSH connections
|
||||
│ ├── env.ts: Checks the environment variables for custom overrides.
|
||||
├── config/
|
||||
│ ├── integration/
|
||||
│ │ ├── abstract.ts: Defines the abstract class for integrations.
|
||||
│ │ ├── docker.ts: Contains the Docker integration.
|
||||
│ │ ├── index.ts: Determines the correct integration to use (if any).
|
||||
│ │ ├── kubernetes.ts: Contains the Kubernetes integration.
|
||||
│ │ ├── proc.ts: Contains the Proc integration.
|
||||
│ ├── env.ts: Checks the environment variables for custom overrides.
|
||||
│ ├── loader.ts: Checks the configuration file and coalesces with ENV.
|
||||
│ ├── schema.ts: Defines the schema for the Headplane configuration.
|
||||
├── headscale/
|
||||
│ ├── api-client.ts: Creates the HTTP client that talks to the Headscale API.
|
||||
│ ├── api-error.ts: Contains the ResponseError definition.
|
||||
│ ├── config-loader.ts: Loads the Headscale configuration (if available).
|
||||
│ ├── config-schema.ts: Defines the schema for the Headscale configuration.
|
||||
├── web/
|
||||
│ ├── agent.ts: Handles setting up the agent WebSocket if needed.
|
||||
│ ├── oidc.ts: Loads and validates an OIDC configuration (if available).
|
||||
│ ├── roles.ts: Contains information about authentication permissions.
|
||||
│ ├── sessions.ts: Initializes the session store and methods to manage it.
|
||||
@@ -0,0 +1,177 @@
|
||||
import { type } from 'arktype';
|
||||
import DockerIntegration from './integration/docker';
|
||||
import KubernetesIntegration from './integration/kubernetes';
|
||||
import ProcIntegration from './integration/proc';
|
||||
import { deprecatedField } from './utils';
|
||||
|
||||
export const pathSupportedKeys = [
|
||||
'server.cookie_secret',
|
||||
'oidc.client_secret',
|
||||
'oidc.headscale_api_key',
|
||||
'integration.agent.pre_authkey',
|
||||
] as const;
|
||||
|
||||
const serverConfig = type({
|
||||
host: 'string.ip = "0.0.0.0"',
|
||||
port: 'number.integer = 3000',
|
||||
data_path: 'string.lower = "/var/lib/headplane/"',
|
||||
|
||||
cookie_secret: '(32 <= string <= 32)',
|
||||
cookie_secure: 'boolean = true',
|
||||
cookie_domain: 'string.lower?',
|
||||
cookie_max_age: 'number.integer = 86400',
|
||||
});
|
||||
|
||||
const partialServerConfig = type({
|
||||
host: 'string.ip?',
|
||||
port: 'number.integer?',
|
||||
data_path: 'string.lower?',
|
||||
|
||||
cookie_secret: '(32 <= string <= 32)?',
|
||||
cookie_secure: 'boolean?',
|
||||
cookie_domain: 'string.lower?',
|
||||
cookie_max_age: 'number.integer?',
|
||||
});
|
||||
|
||||
const headscaleConfig = type({
|
||||
url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)),
|
||||
public_url: type('string.url')
|
||||
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
|
||||
.optional(),
|
||||
config_path: 'string.lower?',
|
||||
config_strict: 'boolean = true',
|
||||
dns_records_path: 'string.lower?',
|
||||
tls_cert_path: 'string.lower?',
|
||||
});
|
||||
|
||||
const partialHeadscaleConfig = type({
|
||||
url: type('string.url')
|
||||
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
|
||||
.optional(),
|
||||
public_url: type('string.url')
|
||||
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
|
||||
.optional(),
|
||||
config_path: 'string.lower?',
|
||||
config_strict: 'boolean?',
|
||||
dns_records_path: 'string.lower?',
|
||||
tls_cert_path: 'string.lower?',
|
||||
});
|
||||
|
||||
const oidcConfig = type({
|
||||
issuer: 'string.url',
|
||||
client_id: 'string',
|
||||
client_secret: 'string',
|
||||
headscale_api_key: 'string',
|
||||
redirect_uri: 'string.url?',
|
||||
disable_api_key_login: 'boolean = false',
|
||||
scope: 'string = "openid email profile"',
|
||||
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
||||
extra_params: 'Record<string, string>?',
|
||||
|
||||
authorization_endpoint: 'string.url?',
|
||||
token_endpoint: 'string.url?',
|
||||
userinfo_endpoint: 'string.url?',
|
||||
|
||||
// Old/deprecated options
|
||||
user_storage_file: 'string.lower = "/var/lib/headplane/users.json"',
|
||||
strict_validation: type('unknown').narrow(deprecatedField()).optional(),
|
||||
token_endpoint_auth_method: type('unknown')
|
||||
.narrow(deprecatedField())
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const partialOidcConfig = type({
|
||||
issuer: 'string.url?',
|
||||
client_id: 'string?',
|
||||
client_secret: 'string?',
|
||||
headscale_api_key: 'string?',
|
||||
redirect_uri: 'string.url?',
|
||||
disable_api_key_login: 'boolean?',
|
||||
scope: 'string?',
|
||||
extra_params: 'Record<string, string>?',
|
||||
profile_picture_source: '"oidc" | "gravatar"?',
|
||||
|
||||
authorization_endpoint: 'string.url?',
|
||||
token_endpoint: 'string.url?',
|
||||
userinfo_endpoint: 'string.url?',
|
||||
|
||||
// Old/deprecated options
|
||||
user_storage_file: 'string.lower?',
|
||||
strict_validation: type('unknown').narrow(deprecatedField()).optional(),
|
||||
token_endpoint_auth_method: type('unknown')
|
||||
.narrow(deprecatedField())
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const agentConfig = type({
|
||||
enabled: 'boolean',
|
||||
host_name: 'string = "headplane-agent"',
|
||||
pre_authkey: 'string',
|
||||
cache_ttl: 'number.integer = 180000',
|
||||
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
|
||||
executable_path: 'string = "/usr/libexec/headplane/agent"',
|
||||
work_dir: 'string = "/var/lib/headplane/agent"',
|
||||
});
|
||||
|
||||
const partialAgentConfig = type({
|
||||
enabled: 'boolean?',
|
||||
host_name: 'string?',
|
||||
pre_authkey: 'string?',
|
||||
cache_ttl: 'number.integer?',
|
||||
cache_path: 'string?',
|
||||
executable_path: 'string?',
|
||||
work_dir: 'string?',
|
||||
});
|
||||
|
||||
const integrationConfig = type({
|
||||
docker: DockerIntegration.configSchema.full,
|
||||
kubernetes: KubernetesIntegration.configSchema.full,
|
||||
proc: ProcIntegration.configSchema.full,
|
||||
agent: agentConfig.optional(),
|
||||
}).partial();
|
||||
|
||||
export const partialIntegrationConfig = type({
|
||||
docker: DockerIntegration.configSchema.partial,
|
||||
kubernetes: KubernetesIntegration.configSchema.partial,
|
||||
proc: ProcIntegration.configSchema.partial,
|
||||
agent: partialAgentConfig.optional(),
|
||||
}).partial();
|
||||
|
||||
export const headplaneConfig = type({
|
||||
debug: 'boolean = false',
|
||||
server: serverConfig,
|
||||
headscale: headscaleConfig,
|
||||
oidc: oidcConfig.optional(),
|
||||
integration: integrationConfig.optional(),
|
||||
}).onDeepUndeclaredKey('delete');
|
||||
|
||||
export const partialHeadplaneConfig = type({
|
||||
debug: 'boolean?',
|
||||
server: partialServerConfig.optional(),
|
||||
headscale: partialHeadscaleConfig.optional(),
|
||||
oidc: partialOidcConfig.optional(),
|
||||
integration: partialIntegrationConfig.optional(),
|
||||
});
|
||||
|
||||
export type HeadplaneConfig = typeof headplaneConfig.infer;
|
||||
export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer;
|
||||
|
||||
type DotNotationToObjects<
|
||||
T extends string,
|
||||
V,
|
||||
> = T extends `${infer K}.${infer Rest}`
|
||||
? { [P in K]?: DotNotationToObjects<Rest, V> }
|
||||
: { [P in `${T}_path`]?: V };
|
||||
|
||||
type ObjectDeepMerge<T> = T extends object
|
||||
? {
|
||||
[K in keyof T]: T[K] extends object ? ObjectDeepMerge<T[K]> : T[K];
|
||||
}
|
||||
: T;
|
||||
|
||||
type ConfigWithPathKeys = ObjectDeepMerge<
|
||||
DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined>
|
||||
>;
|
||||
|
||||
export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig &
|
||||
ConfigWithPathKeys;
|
||||
@@ -1,76 +0,0 @@
|
||||
import { exit } from 'node:process';
|
||||
import { type } from 'arktype';
|
||||
import log from '~/utils/log';
|
||||
|
||||
// Custom type for boolean environment variables, allowing for values like
|
||||
// 1, true, yes, and on to count as a truthy value.
|
||||
const booleanEnv = type('string | undefined').pipe((v) => {
|
||||
return ['1', 'true', 'yes', 'on'].includes(v?.toLowerCase() ?? '');
|
||||
});
|
||||
|
||||
export const envVariables = {
|
||||
debugLog: 'HEADPLANE_DEBUG_LOG',
|
||||
envOverrides: 'HEADPLANE_LOAD_ENV_OVERRIDES',
|
||||
configPath: 'HEADPLANE_CONFIG_PATH',
|
||||
} as const;
|
||||
|
||||
export function configureLogger(env: string | undefined) {
|
||||
const result = booleanEnv(env);
|
||||
if (result instanceof type.errors) {
|
||||
log.error(
|
||||
'config',
|
||||
'HEADPLANE_DEBUG_LOG value is invalid: %s',
|
||||
result.summary,
|
||||
);
|
||||
log.info('config', 'Using a default value: false');
|
||||
log.debug = () => {}; // Disable debug logging if the value is invalid
|
||||
log.debugEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (result === false) {
|
||||
log.debug = () => {}; // Disable debug logging if the value is false
|
||||
log.debugEnabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
log.debug('config', 'Debug logging has been enabled');
|
||||
log.debug('config', 'It is recommended this be disabled in production');
|
||||
}
|
||||
|
||||
export interface EnvOverrides {
|
||||
loadEnv: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export function configureConfig(overrides: {
|
||||
loadEnv: string | undefined;
|
||||
path: string | undefined;
|
||||
}): EnvOverrides {
|
||||
const loadResult = booleanEnv(overrides.loadEnv);
|
||||
if (loadResult instanceof type.errors) {
|
||||
log.error(
|
||||
'config',
|
||||
'HEADPLANE_LOAD_ENV_OVERRIDES value is invalid: %s',
|
||||
loadResult.summary,
|
||||
);
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const pathResult = type('string | undefined')(overrides.path);
|
||||
if (pathResult instanceof type.errors) {
|
||||
log.error(
|
||||
'config',
|
||||
'HEADPLANE_CONFIG_PATH value is invalid: %s',
|
||||
pathResult.summary,
|
||||
);
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
loadEnv: loadResult,
|
||||
path: pathResult ?? '/etc/headplane/config.yaml',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
interface ErrorCodes {
|
||||
CONFLICTING_SECRET_PATH_FIELD: {
|
||||
fieldName: string;
|
||||
};
|
||||
|
||||
INVALID_REQUIRED_FIELDS: {
|
||||
messages: string[];
|
||||
};
|
||||
|
||||
MISSING_INTERPOLATION_VARIABLE: {
|
||||
pathKey: string;
|
||||
variableName: string;
|
||||
};
|
||||
|
||||
MISSING_SECRET_FILE: {
|
||||
pathKey: string;
|
||||
filePath: string;
|
||||
};
|
||||
}
|
||||
|
||||
const translationsWithVars: {
|
||||
[K in keyof ErrorCodes]: (vars: ErrorCodes[K]) => string;
|
||||
} = {
|
||||
CONFLICTING_SECRET_PATH_FIELD: ({ fieldName }) =>
|
||||
`Both "${fieldName}" and "${fieldName}_path" are set; please provide only one of these fields.`,
|
||||
INVALID_REQUIRED_FIELDS: ({ messages }) =>
|
||||
`The configuration is missing required fields or has invalid values:\n- ${messages.join('\n- ')}`,
|
||||
MISSING_INTERPOLATION_VARIABLE: ({ pathKey, variableName }) =>
|
||||
`Could not resolve environment variable "${variableName}" for configuration key "${pathKey}".`,
|
||||
|
||||
MISSING_SECRET_FILE: ({ pathKey, filePath }) =>
|
||||
`The secret file specified in "${pathKey}" could not be accessed at path "${filePath}". Please ensure the file exists and is readable.`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Custom error class for configuration-related errors.
|
||||
*/
|
||||
export class ConfigError extends Error {
|
||||
/**
|
||||
* The error code representing the type of configuration error.
|
||||
*/
|
||||
code: keyof ErrorCodes;
|
||||
|
||||
/**
|
||||
* Creates a new ConfigError instance.
|
||||
*
|
||||
* @param code The error code
|
||||
* @param vars The variables to interpolate into the error message
|
||||
*/
|
||||
constructor(code: keyof ErrorCodes, vars: unknown) {
|
||||
super(
|
||||
translationsWithVars[code](
|
||||
vars as (typeof translationsWithVars)[typeof code] extends (
|
||||
vars: infer U,
|
||||
) => string
|
||||
? U
|
||||
: never,
|
||||
),
|
||||
);
|
||||
this.code = code;
|
||||
this.name = 'ConfigError';
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a ConfigError instance.
|
||||
*
|
||||
* @param code The error code
|
||||
* @param vars The variables to interpolate into the error message
|
||||
* @returns A new ConfigError instance
|
||||
*/
|
||||
static from<K extends keyof ErrorCodes>(code: K, vars: ErrorCodes[K]) {
|
||||
return new ConfigError(code, vars);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ApiClient } from '~/server/headscale/api-client';
|
||||
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
|
||||
|
||||
export abstract class Integration<T> {
|
||||
protected context: NonNullable<T>;
|
||||
@@ -11,6 +11,6 @@ export abstract class Integration<T> {
|
||||
}
|
||||
|
||||
abstract isAvailable(): Promise<boolean> | boolean;
|
||||
abstract onConfigChange(client: ApiClient): Promise<void> | void;
|
||||
abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void;
|
||||
abstract get name(): string;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { constants, access } from 'node:fs/promises';
|
||||
import { access, constants } from 'node:fs/promises';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { type } from 'arktype';
|
||||
import { Client } from 'undici';
|
||||
import { ApiClient } from '~/server/headscale/api-client';
|
||||
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
|
||||
import log from '~/utils/log';
|
||||
import type { HeadplaneConfig } from '../schema';
|
||||
import { Integration } from './abstract';
|
||||
|
||||
interface DockerContainer {
|
||||
@@ -11,8 +11,25 @@ interface DockerContainer {
|
||||
Names: string[];
|
||||
}
|
||||
|
||||
type T = NonNullable<HeadplaneConfig['integration']>['docker'];
|
||||
export default class DockerIntegration extends Integration<T> {
|
||||
const configSchema = {
|
||||
full: type({
|
||||
enabled: 'boolean',
|
||||
container_name: 'string?',
|
||||
container_label: 'string = "me.tale.headplane.target=headscale"',
|
||||
socket: 'string = "unix:///var/run/docker.sock"',
|
||||
}),
|
||||
|
||||
partial: type({
|
||||
enabled: 'boolean?',
|
||||
container_name: 'string?',
|
||||
container_label: 'string?',
|
||||
socket: 'string?',
|
||||
}).partial(),
|
||||
};
|
||||
|
||||
export default class DockerIntegration extends Integration<
|
||||
typeof configSchema.full.infer
|
||||
> {
|
||||
private maxAttempts = 10;
|
||||
private client: Client | undefined;
|
||||
private containerId: string | undefined;
|
||||
@@ -21,6 +38,10 @@ export default class DockerIntegration extends Integration<T> {
|
||||
return 'Docker';
|
||||
}
|
||||
|
||||
static get configSchema() {
|
||||
return configSchema;
|
||||
}
|
||||
|
||||
async getContainerName(label: string, value: string): Promise<string> {
|
||||
if (!this.client) {
|
||||
throw new Error('Docker client is not initialized');
|
||||
@@ -60,7 +81,7 @@ export default class DockerIntegration extends Integration<T> {
|
||||
// Basic configuration check, the name overrides the container_label
|
||||
// selector because of legacy support.
|
||||
const { container_name, container_label } = this.context;
|
||||
if (container_name.length === 0 && container_label.length === 0) {
|
||||
if (container_name?.length === 0 && container_label.length === 0) {
|
||||
log.error(
|
||||
'config',
|
||||
'Missing a Docker `container_name` or `container_label`',
|
||||
@@ -94,7 +115,7 @@ export default class DockerIntegration extends Integration<T> {
|
||||
|
||||
try {
|
||||
log.info('config', 'Checking API: %s', fetchU);
|
||||
await fetch(new URL('/v1.30/version', fetchU).href);
|
||||
await fetch(new URL('/v1.44/version', fetchU).href);
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to connect to Docker API: %s', error);
|
||||
log.debug('config', 'Connection error: %o', error);
|
||||
@@ -127,7 +148,7 @@ export default class DockerIntegration extends Integration<T> {
|
||||
|
||||
const qp = new URLSearchParams({
|
||||
filters: JSON.stringify(
|
||||
container_name.length > 0
|
||||
container_name != null && container_name.length > 0
|
||||
? { name: [container_name] }
|
||||
: { label: [container_label] },
|
||||
),
|
||||
@@ -140,7 +161,7 @@ export default class DockerIntegration extends Integration<T> {
|
||||
);
|
||||
const res = await this.client.request({
|
||||
method: 'GET',
|
||||
path: `/v1.30/containers/json?${qp.toString()}`,
|
||||
path: `/v1.44/containers/json?${qp.toString()}`,
|
||||
});
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
@@ -151,7 +172,7 @@ export default class DockerIntegration extends Integration<T> {
|
||||
|
||||
const data = (await res.body.json()) as DockerContainer[];
|
||||
if (data.length > 1) {
|
||||
if (container_name.length > 0) {
|
||||
if (container_name != null && container_name.length > 0) {
|
||||
log.error(
|
||||
'config',
|
||||
`Found multiple containers with name ${container_name}`,
|
||||
@@ -167,7 +188,7 @@ export default class DockerIntegration extends Integration<T> {
|
||||
}
|
||||
|
||||
if (data.length === 0) {
|
||||
if (container_name.length > 0) {
|
||||
if (container_name != null && container_name.length > 0) {
|
||||
log.error(
|
||||
'config',
|
||||
`No container found with the name ${container_name}`,
|
||||
@@ -193,7 +214,7 @@ export default class DockerIntegration extends Integration<T> {
|
||||
return this.client !== undefined && this.containerId !== undefined;
|
||||
}
|
||||
|
||||
async onConfigChange(client: ApiClient) {
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
if (!this.client) {
|
||||
return;
|
||||
}
|
||||
@@ -211,7 +232,7 @@ export default class DockerIntegration extends Integration<T> {
|
||||
|
||||
const response = await this.client.request({
|
||||
method: 'POST',
|
||||
path: `/v1.30/containers/${this.containerId}/restart`,
|
||||
path: `/v1.44/containers/${this.containerId}/restart`,
|
||||
});
|
||||
|
||||
if (response.statusCode !== 204) {
|
||||
@@ -233,14 +254,14 @@ export default class DockerIntegration extends Integration<T> {
|
||||
while (attempts <= this.maxAttempts) {
|
||||
try {
|
||||
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
|
||||
const status = await client.healthcheck();
|
||||
const status = await client.isHealthy();
|
||||
if (status === false) {
|
||||
throw new Error('Headscale is not running');
|
||||
}
|
||||
|
||||
log.info('config', 'Headscale is up and running');
|
||||
return;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (attempts < this.maxAttempts) {
|
||||
attempts++;
|
||||
await setTimeout(1000);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { HeadplaneConfig } from '~/server/config/schema';
|
||||
import log from '~/utils/log';
|
||||
import type { HeadplaneConfig } from '../config-schema';
|
||||
import dockerIntegration from './docker';
|
||||
import kubernetesIntegration from './kubernetes';
|
||||
import procIntegration from './proc';
|
||||
@@ -47,16 +47,16 @@ function getIntegration(integration: HeadplaneConfig['integration']) {
|
||||
|
||||
if (docker?.enabled) {
|
||||
log.info('config', 'Using Docker integration');
|
||||
return new dockerIntegration(integration?.docker);
|
||||
return new dockerIntegration(integration!.docker!);
|
||||
}
|
||||
|
||||
if (k8s?.enabled) {
|
||||
log.info('config', 'Using Kubernetes integration');
|
||||
return new kubernetesIntegration(integration?.kubernetes);
|
||||
return new kubernetesIntegration(integration!.kubernetes!);
|
||||
}
|
||||
|
||||
if (proc?.enabled) {
|
||||
log.info('config', 'Using Proc integration');
|
||||
return new procIntegration(integration?.proc);
|
||||
return new procIntegration(integration!.proc!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { platform } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { kill } from 'node:process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
|
||||
import { ApiClient } from '~/server/headscale/api-client';
|
||||
import { type } from 'arktype';
|
||||
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
|
||||
import log from '~/utils/log';
|
||||
import { HeadplaneConfig } from '../schema';
|
||||
import { Integration } from './abstract';
|
||||
|
||||
// https://github.com/kubernetes-client/javascript/blob/055b83c6504dfd1b2a2d081efd974163c6cbb808/src/config.ts#L40
|
||||
@@ -15,8 +15,23 @@ const svcCaPath = `${svcRoot}/ca.crt`;
|
||||
const svcTokenPath = `${svcRoot}/token`;
|
||||
const svcNamespacePath = `${svcRoot}/namespace`;
|
||||
|
||||
type T = NonNullable<HeadplaneConfig['integration']>['kubernetes'];
|
||||
export default class KubernetesIntegration extends Integration<T> {
|
||||
const configSchema = {
|
||||
full: type({
|
||||
enabled: 'boolean',
|
||||
pod_name: 'string',
|
||||
validate_manifest: 'boolean = true',
|
||||
}),
|
||||
|
||||
partial: type({
|
||||
enabled: 'boolean?',
|
||||
pod_name: 'string?',
|
||||
validate_manifest: 'boolean?',
|
||||
}).partial(),
|
||||
};
|
||||
|
||||
export default class KubernetesIntegration extends Integration<
|
||||
typeof configSchema.full.infer
|
||||
> {
|
||||
private pid: number | undefined;
|
||||
private maxAttempts = 10;
|
||||
|
||||
@@ -24,6 +39,10 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
return 'Kubernetes (k8s)';
|
||||
}
|
||||
|
||||
static get configSchema() {
|
||||
return configSchema;
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
if (platform() !== 'linux') {
|
||||
log.error('config', 'Kubernetes is only available on Linux');
|
||||
@@ -202,7 +221,7 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
}
|
||||
}
|
||||
|
||||
async onConfigChange(client: ApiClient) {
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
if (!this.pid) {
|
||||
return;
|
||||
}
|
||||
@@ -220,14 +239,14 @@ export default class KubernetesIntegration extends Integration<T> {
|
||||
while (attempts <= this.maxAttempts) {
|
||||
try {
|
||||
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
|
||||
const status = await client.healthcheck();
|
||||
const status = await client.isHealthy();
|
||||
if (status === false) {
|
||||
throw new Error('Headscale is not running');
|
||||
}
|
||||
|
||||
log.info('config', 'Headscale is up and running');
|
||||
return;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (attempts < this.maxAttempts) {
|
||||
attempts++;
|
||||
await setTimeout(1000);
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { readFile, readdir } from 'node:fs/promises';
|
||||
import { readdir, readFile } from 'node:fs/promises';
|
||||
import { platform } from 'node:os';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { kill } from 'node:process';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import { ApiClient } from '~/server/headscale/api-client';
|
||||
import { type } from 'arktype';
|
||||
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
|
||||
import log from '~/utils/log';
|
||||
import { HeadplaneConfig } from '../schema';
|
||||
import type { HeadplaneConfig } from '../config-schema';
|
||||
import { Integration } from './abstract';
|
||||
|
||||
type T = NonNullable<HeadplaneConfig['integration']>['proc'];
|
||||
export default class ProcIntegration extends Integration<T> {
|
||||
const configSchema = {
|
||||
full: type({
|
||||
enabled: 'boolean',
|
||||
}),
|
||||
|
||||
partial: type({
|
||||
enabled: 'boolean?',
|
||||
}).partial(),
|
||||
};
|
||||
|
||||
export default class ProcIntegration extends Integration<
|
||||
typeof configSchema.full.infer
|
||||
> {
|
||||
private pid: number | undefined;
|
||||
private maxAttempts = 10;
|
||||
|
||||
@@ -17,6 +29,10 @@ export default class ProcIntegration extends Integration<T> {
|
||||
return 'Native Linux (/proc)';
|
||||
}
|
||||
|
||||
static get configSchema() {
|
||||
return configSchema;
|
||||
}
|
||||
|
||||
async isAvailable() {
|
||||
if (platform() !== 'linux') {
|
||||
log.error('config', '/proc is only available on Linux');
|
||||
@@ -68,30 +84,37 @@ export default class ProcIntegration extends Integration<T> {
|
||||
pids.join(', '),
|
||||
);
|
||||
|
||||
log.debug('config', 'Checking if any of them have Parent PID = 1, assuming thats the correct PID');
|
||||
const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/;
|
||||
for (const pid of pids) {
|
||||
const pidStatusPath = join('/proc', pid.toString(), 'status');
|
||||
try {
|
||||
log.debug('config', 'Reading %s', pidStatusPath);
|
||||
const pidData = await readFile(pidStatusPath, 'utf8');
|
||||
const ppidResult = pidData.match(ppidRegex);
|
||||
log.debug(
|
||||
'config',
|
||||
'Checking if any of them have Parent PID = 1, assuming thats the correct PID',
|
||||
);
|
||||
const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/;
|
||||
for (const pid of pids) {
|
||||
const pidStatusPath = join('/proc', pid.toString(), 'status');
|
||||
try {
|
||||
log.debug('config', 'Reading %s', pidStatusPath);
|
||||
const pidData = await readFile(pidStatusPath, 'utf8');
|
||||
const ppidResult = pidData.match(ppidRegex);
|
||||
|
||||
if (ppidResult !== null) {
|
||||
const potentialPPid = Number.parseInt(ppidResult[1], 10);
|
||||
if (potentialPPid === 1) {
|
||||
this.pid = pid;
|
||||
log.info('config', 'Found potential Headscale process with PID: %d based on Parent PID = 1', this.pid);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to read %s: %s', pidStatusPath, error);
|
||||
}
|
||||
}
|
||||
if (ppidResult !== null) {
|
||||
const potentialPPid = Number.parseInt(ppidResult[1], 10);
|
||||
if (potentialPPid === 1) {
|
||||
this.pid = pid;
|
||||
log.info(
|
||||
'config',
|
||||
'Found potential Headscale process with PID: %d based on Parent PID = 1',
|
||||
this.pid,
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to read %s: %s', pidStatusPath, error);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (pids.length === 0) {
|
||||
log.error('config', 'Could not find Headscale process');
|
||||
@@ -107,7 +130,7 @@ export default class ProcIntegration extends Integration<T> {
|
||||
}
|
||||
}
|
||||
|
||||
async onConfigChange(client: ApiClient) {
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
if (!this.pid) {
|
||||
return;
|
||||
}
|
||||
@@ -125,7 +148,7 @@ export default class ProcIntegration extends Integration<T> {
|
||||
while (attempts <= this.maxAttempts) {
|
||||
try {
|
||||
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
|
||||
const status = await client.healthcheck();
|
||||
const status = await client.isHealthy();
|
||||
if (status === false) {
|
||||
log.error('config', 'Headscale is not running');
|
||||
return;
|
||||
@@ -133,7 +156,7 @@ export default class ProcIntegration extends Integration<T> {
|
||||
|
||||
log.info('config', 'Headscale is up and running');
|
||||
return;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
if (attempts < this.maxAttempts) {
|
||||
attempts++;
|
||||
await setTimeout(1000);
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
import { access, constants, readFile } from 'node:fs/promises';
|
||||
import { type } from 'arktype';
|
||||
import { load } from 'js-yaml';
|
||||
import log from '~/utils/log';
|
||||
import {
|
||||
headplaneConfig,
|
||||
PartialHeadplaneConfig,
|
||||
partialHeadplaneConfig,
|
||||
pathSupportedKeys,
|
||||
} from './config-schema';
|
||||
import { ConfigError } from './error';
|
||||
|
||||
/**
|
||||
* Main entrypoint that attempts to load and merge configuration from both
|
||||
* a YAML config file (if available) and environment variables. Importantly,
|
||||
* the environment variables will override any values set in the config file.
|
||||
*
|
||||
* The function also supports loading secret values from file paths for
|
||||
* specific configuration keys (e.g., certificates, private keys) by checking
|
||||
* for corresponding `_path` suffixed environment variables or config file
|
||||
* entries.
|
||||
*
|
||||
* @param configPathOverride Used for testing to override the config file path
|
||||
* @returns @ref{HeadplaneConfig} The fully validated configuration
|
||||
* @throws {Error} If there are validation errors in the final configuration
|
||||
*/
|
||||
export async function loadConfig(configPathOverride?: string) {
|
||||
const configPath =
|
||||
configPathOverride != null
|
||||
? configPathOverride
|
||||
: process.env.HEADPLANE_CONFIG_PATH != null
|
||||
? String(process.env.HEADPLANE_CONFIG_PATH)
|
||||
: '/etc/headplane/config.yaml';
|
||||
|
||||
const fileConfig = await loadConfigFile(configPath);
|
||||
const envConfig = await loadConfigEnv();
|
||||
|
||||
const combinedConfig = deepMerge(fileConfig, envConfig);
|
||||
await loadConfigKeyPaths(combinedConfig);
|
||||
|
||||
const finalConfig = headplaneConfig(combinedConfig);
|
||||
if (finalConfig instanceof type.errors) {
|
||||
throw ConfigError.from('INVALID_REQUIRED_FIELDS', {
|
||||
messages: finalConfig.map((e) => e.toString()),
|
||||
});
|
||||
}
|
||||
|
||||
return finalConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to load configuration from a YAML file at the specified path.
|
||||
* If the file is not accessible, it returns undefined.
|
||||
*
|
||||
* @param path The file path to load the configuration from
|
||||
* @returns A partial configuration object or undefined
|
||||
* @throws {Error} If there are validation errors in the loaded configuration
|
||||
*/
|
||||
export async function loadConfigFile(path: string) {
|
||||
try {
|
||||
await access(path, constants.R_OK);
|
||||
} catch {
|
||||
log.info('config', 'Could not access config file at path: %s', path);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBuffer = await readFile(path, 'utf8');
|
||||
const rawConfig = load(rawBuffer);
|
||||
const config = partialHeadplaneConfig(rawConfig);
|
||||
if (config instanceof type.errors) {
|
||||
throw ConfigError.from('INVALID_REQUIRED_FIELDS', {
|
||||
messages: config.map((e) => e.toString()),
|
||||
});
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads configuration overrides from environment variables prefixed with
|
||||
* `HEADPLANE_`. Nested configuration keys can be represented using double
|
||||
* underscores (`__`). For example, `HEADPLANE_SERVER__PORT=8080` would set
|
||||
* the `server.port` configuration key to `8080`.
|
||||
*
|
||||
* @returns A partial configuration object or undefined
|
||||
* @throws {Error} If there are validation errors in the loaded configuration
|
||||
*/
|
||||
export async function loadConfigEnv() {
|
||||
if (process.env.HEADPLANE_LOAD_ENV_OVERRIDES != null) {
|
||||
log.warn(
|
||||
'config',
|
||||
'HEADPLANE_LOAD_ENV_OVERRIDES is deprecated and will be removed in future versions',
|
||||
);
|
||||
log.warn(
|
||||
'config',
|
||||
'Environment variables are always loaded and `.env` files are no longer supported',
|
||||
);
|
||||
}
|
||||
|
||||
const rawConfig: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value == null || !key.startsWith('HEADPLANE_')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedValue = parseEnvValue(value);
|
||||
const configKey = key.slice('HEADPLANE_'.length).toLowerCase();
|
||||
deepSet(rawConfig, configKey.split('__'), parsedValue);
|
||||
}
|
||||
|
||||
const config = partialHeadplaneConfig(rawConfig);
|
||||
if (config instanceof type.errors) {
|
||||
throw ConfigError.from('INVALID_REQUIRED_FIELDS', {
|
||||
messages: config.map((e) => e.toString()),
|
||||
});
|
||||
}
|
||||
|
||||
return Object.keys(config).length > 0 ? config : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deeply merges multiple objects together. Later objects in the arguments
|
||||
* list will override properties of earlier objects.
|
||||
*
|
||||
* @param objects The objects to merge
|
||||
* @returns The merged object
|
||||
*/
|
||||
function deepMerge<T>(...objects: (T | undefined)[]): T {
|
||||
const result: { [key: string]: unknown } = {};
|
||||
for (const obj of objects.filter((o) => o != null)) {
|
||||
for (const [key, value] of Object.entries(
|
||||
obj as {
|
||||
[key: string]: unknown;
|
||||
},
|
||||
)) {
|
||||
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
if (
|
||||
result[key] == null ||
|
||||
typeof result[key] !== 'object' ||
|
||||
Array.isArray(result[key])
|
||||
) {
|
||||
result[key] = {};
|
||||
}
|
||||
result[key] = deepMerge(result[key], value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result as T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a value deeply within an object based on the provided path.
|
||||
*
|
||||
* @param obj The object to set the value in
|
||||
* @param path An array of keys representing the path to set
|
||||
* @param value The value to set at the specified path
|
||||
*/
|
||||
function deepSet(
|
||||
obj: { [key: string]: unknown },
|
||||
path: string[],
|
||||
value: unknown,
|
||||
): void {
|
||||
let current = obj;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const key = path[i];
|
||||
if (current[key] == null || typeof current[key] !== 'object') {
|
||||
current[key] = {};
|
||||
}
|
||||
|
||||
current = current[key] as { [key: string]: unknown };
|
||||
}
|
||||
|
||||
current[path[path.length - 1]] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses an environment variable string value into an appropriate type.
|
||||
* Supports booleans, null, undefined, and numbers. Falls back to string.
|
||||
*
|
||||
* @param value The environment variable string value
|
||||
* @returns The parsed value
|
||||
*/
|
||||
function parseEnvValue(value: string): unknown {
|
||||
const v = value.trim().toLowerCase();
|
||||
if (v === 'true') return true;
|
||||
if (v === 'false') return false;
|
||||
if (v === 'null') return null;
|
||||
if (v === 'undefined') return undefined;
|
||||
|
||||
if (/^-?\d+(\.\d+)?$/.test(v)) {
|
||||
const num = Number(v);
|
||||
if (!Number.isNaN(num)) return num;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* For configuration keys that support loading from file paths (e.g.,
|
||||
* certificates, private keys), this function checks for corresponding
|
||||
* `_path` suffixed keys and loads the file content if the main key is
|
||||
* not already set.
|
||||
*
|
||||
* @param partial The partial configuration object to update
|
||||
*/
|
||||
export async function loadConfigKeyPaths(partial: PartialHeadplaneConfig) {
|
||||
for (const key of pathSupportedKeys) {
|
||||
const pathKey = `${key}_path`;
|
||||
const pathValue = deepGet(partial, pathKey.split('.'));
|
||||
const existing = deepGet(partial, key.split('.'));
|
||||
|
||||
if (pathValue == null || typeof pathValue !== 'string') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
throw ConfigError.from('CONFLICTING_SECRET_PATH_FIELD', {
|
||||
fieldName: key,
|
||||
});
|
||||
}
|
||||
|
||||
const realPath = pathValue.replace(/\$\{([^}]+)\}/g, (_, variableName) => {
|
||||
const value = process.env[variableName];
|
||||
if (value === undefined) {
|
||||
throw ConfigError.from('MISSING_INTERPOLATION_VARIABLE', {
|
||||
pathKey: `${key}_path`,
|
||||
variableName: variableName,
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
});
|
||||
|
||||
try {
|
||||
const fileContent = await readFile(realPath, 'utf8');
|
||||
deepSet(partial, key.split('.'), fileContent.trim().normalize());
|
||||
} catch {
|
||||
throw ConfigError.from('MISSING_SECRET_FILE', {
|
||||
pathKey: `${key}_path`,
|
||||
filePath: realPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deeply retrieves a value from an object based on the provided path.
|
||||
*
|
||||
* @param obj The object to retrieve the value from
|
||||
* @param path An array of keys representing the path to retrieve
|
||||
* @returns The value at the specified path or undefined if not found
|
||||
*/
|
||||
function deepGet(obj: { [key: string]: unknown }, path: string[]): unknown {
|
||||
let current = obj;
|
||||
for (const segment of path) {
|
||||
if (current == null || typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
current = current[segment] as { [key: string]: unknown };
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
@@ -1,303 +0,0 @@
|
||||
import { access, constants, readFile } from 'node:fs/promises';
|
||||
import { env } from 'node:process';
|
||||
import { type } from 'arktype';
|
||||
import { configDotenv } from 'dotenv';
|
||||
import { parseDocument } from 'yaml';
|
||||
import log from '~/utils/log';
|
||||
import { EnvOverrides, envVariables } from './env';
|
||||
import {
|
||||
HeadplaneConfig,
|
||||
headplaneConfig,
|
||||
partialHeadplaneConfig,
|
||||
} from './schema';
|
||||
|
||||
// Custom error for config issues
|
||||
export class ConfigError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ConfigError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpolate environment variables in a string
|
||||
* Replaces ${VAR_NAME} patterns with the actual environment variable values
|
||||
*/
|
||||
export function interpolateEnvVars(str: string): string {
|
||||
return str.replace(/\$\{([^}]+)\}/g, (_, varName) => {
|
||||
const value = env[varName];
|
||||
if (value === undefined) {
|
||||
throw new ConfigError(`Environment variable "${varName}" not found`);
|
||||
}
|
||||
return value;
|
||||
});
|
||||
}
|
||||
|
||||
// loadConfig is a has a lifetime of the entire application and is
|
||||
// used to load the configuration for Headplane. It is called once.
|
||||
//
|
||||
// TODO: Potential for file watching on the configuration
|
||||
// But this may not be necessary as a use-case anyways
|
||||
export async function loadConfig({ loadEnv, path }: EnvOverrides) {
|
||||
log.debug('config', 'Loading configuration file: %s', path);
|
||||
await validateConfigPath(path);
|
||||
|
||||
const data = await loadConfigFile(path);
|
||||
if (!data) {
|
||||
throw new ConfigError('Failed to load configuration file');
|
||||
}
|
||||
|
||||
let config = validateConfig({ ...data, debug: log.debugEnabled });
|
||||
|
||||
if (!loadEnv) {
|
||||
log.debug('config', 'Environment variable overrides are disabled');
|
||||
log.debug('config', 'This also disables the loading of a .env file');
|
||||
const moddedConfig = await loadSecretsFromFiles(config);
|
||||
log.debug('config', 'Loaded file-based secrets');
|
||||
return moddedConfig;
|
||||
}
|
||||
|
||||
log.info('config', 'Loading a .env file (if available)');
|
||||
configDotenv({ override: true, quiet: true });
|
||||
const merged = coalesceEnv(config);
|
||||
if (merged) config = merged;
|
||||
if (config.headscale && typeof config.headscale.config_path === 'string') {
|
||||
config.headscale.config_path = interpolateEnvVars(
|
||||
config.headscale.config_path,
|
||||
);
|
||||
}
|
||||
|
||||
const moddedConfig = await loadSecretsFromFiles(config);
|
||||
log.debug('config', 'Loaded file-based secrets');
|
||||
|
||||
return moddedConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively walks the config object; for any key in the whitelist of secret path keys,
|
||||
* reads that file and assigns its contents to the corresponding key
|
||||
* without the suffix, then removes the "_path" property.
|
||||
*/
|
||||
const SECRET_PATH_KEYS = [
|
||||
'pre_authkey_path',
|
||||
'client_secret_path',
|
||||
'headscale_api_key_path',
|
||||
'cookie_secret_path',
|
||||
] as const;
|
||||
|
||||
// For fast set hashing lookups, but we still need the array for typings
|
||||
const SECRET_PATH_KEY_SET = new Set<string>(SECRET_PATH_KEYS);
|
||||
|
||||
type SecretPathKey = (typeof SECRET_PATH_KEYS)[number];
|
||||
type StripPath<S extends string> = S extends `${infer T}_path` ? T : never;
|
||||
type KeysToPromote<T> = Extract<keyof T & string, SecretPathKey>;
|
||||
type MappedKeys<T> = StripPath<KeysToPromote<T>>;
|
||||
|
||||
type NonNullablized<T> = Omit<T, KeysToPromote<T> | MappedKeys<T>> & {
|
||||
[K in MappedKeys<T>]-?: string;
|
||||
};
|
||||
|
||||
type NestedNonNullablized<T> = T extends readonly (infer U)[]
|
||||
? readonly NestedNonNullablized<U>[]
|
||||
: T extends (infer U)[]
|
||||
? NestedNonNullablized<U>[]
|
||||
: T extends object
|
||||
? {
|
||||
[K in keyof NonNullablized<T>]: NestedNonNullablized<
|
||||
NonNullablized<T>[K]
|
||||
>;
|
||||
}
|
||||
: T;
|
||||
|
||||
async function loadSecretsFromFiles<T extends object>(
|
||||
obj: T,
|
||||
): Promise<NestedNonNullablized<T>> {
|
||||
// Work with a Record so we can mutate/delete properties
|
||||
const record = obj as Record<string, unknown>;
|
||||
|
||||
for (const key of Object.keys(record)) {
|
||||
const val = record[key];
|
||||
|
||||
if (val && typeof val === 'object') {
|
||||
// recurse into nested objects
|
||||
record[key] = await loadSecretsFromFiles(val);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (SECRET_PATH_KEY_SET.has(key) && typeof val === 'string') {
|
||||
try {
|
||||
const path = interpolateEnvVars(val);
|
||||
const content = await readFile(path, 'utf8');
|
||||
const secretKey = key.slice(0, -5); // drop '_path'
|
||||
record[secretKey] = content.trim();
|
||||
delete record[key];
|
||||
log.debug('config', 'Loaded secret from %s → %s', val, secretKey);
|
||||
} catch (err) {
|
||||
if (err instanceof ConfigError) throw err;
|
||||
log.error('config', 'Failed to read secret file %s: %s', val, err);
|
||||
throw new ConfigError(`Failed to read secret file ${val}: ${err}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cast back to the original T so callers keep their precise type
|
||||
return record as NestedNonNullablized<T>;
|
||||
}
|
||||
|
||||
async function validateConfigPath(path: string) {
|
||||
try {
|
||||
await access(path, constants.F_OK | constants.R_OK);
|
||||
log.info('config', 'Found a valid configuration file at %s', path);
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.error('config', 'Unable to read a configuration file at %s', path);
|
||||
log.error('config', '%s', error);
|
||||
throw new ConfigError(
|
||||
`Unable to read configuration file at ${path}: ${error}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConfigFile(path: string): Promise<unknown> {
|
||||
log.debug('config', 'Reading configuration file at %s', path);
|
||||
try {
|
||||
const data = await readFile(path, 'utf8');
|
||||
const configYaml = parseDocument(data);
|
||||
if (configYaml.errors.length > 0) {
|
||||
log.error('config', 'Cannot parse configuration file at %s', path);
|
||||
for (const error of configYaml.errors) {
|
||||
log.error('config', ` - ${error.toString()}`);
|
||||
}
|
||||
|
||||
throw new ConfigError(`Cannot parse configuration file at ${path}`);
|
||||
}
|
||||
|
||||
if (configYaml.warnings.length > 0) {
|
||||
log.warn(
|
||||
'config',
|
||||
'Warnings while parsing configuration file at %s',
|
||||
path,
|
||||
);
|
||||
for (const warning of configYaml.warnings) {
|
||||
log.warn('config', ` - ${warning.toString()}`);
|
||||
}
|
||||
}
|
||||
|
||||
return configYaml.toJSON() as unknown;
|
||||
} catch (e) {
|
||||
log.error('config', 'Error reading configuration file at %s', path);
|
||||
log.error('config', '%s', e);
|
||||
throw new ConfigError(`Error reading configuration file at ${path}: ${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function validateConfig(config: unknown) {
|
||||
log.debug('config', 'Validating Headplane configuration');
|
||||
const result = headplaneConfig(config);
|
||||
if (result instanceof type.errors) {
|
||||
const errorMessages = [];
|
||||
for (const [number, error] of result.entries()) {
|
||||
const errorMsg = error.toString();
|
||||
log.error('config', ` - (${number}): ${errorMsg}`);
|
||||
errorMessages.push(errorMsg);
|
||||
}
|
||||
throw new ConfigError(errorMessages.join('\n'));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function coalesceEnv(config: HeadplaneConfig) {
|
||||
const envConfig: Record<string, unknown> = {};
|
||||
const rootKeys: string[] = Object.values(envVariables);
|
||||
|
||||
// Typescript is still insanely stupid at nullish filtering
|
||||
const vars = Object.entries(env).filter(([key, value]) => {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!key.startsWith('HEADPLANE_')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Filter out the rootEnv configurations
|
||||
if (rootKeys.includes(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}) as [string, string][];
|
||||
|
||||
log.debug('config', 'Coalescing %s environment variables', vars.length);
|
||||
for (const [key, value] of vars) {
|
||||
const configPath = key.replace('HEADPLANE_', '').toLowerCase().split('__');
|
||||
log.debug(
|
||||
'config',
|
||||
` - ${key}=${new Array(value.length).fill('*').join('')}`,
|
||||
);
|
||||
|
||||
let current = envConfig;
|
||||
while (configPath.length > 1) {
|
||||
const path = configPath.shift() as string;
|
||||
if (!(path in current)) {
|
||||
current[path] = {};
|
||||
}
|
||||
|
||||
current = current[path] as Record<string, unknown>;
|
||||
}
|
||||
|
||||
current[configPath[0]] = value;
|
||||
}
|
||||
|
||||
const toMerge = coalesceConfig(envConfig);
|
||||
if (!toMerge) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Deep merge the environment variables into the configuration
|
||||
// This will overwrite any existing values in the configuration
|
||||
return deepMerge(config, toMerge);
|
||||
}
|
||||
|
||||
export function coalesceConfig(config: unknown) {
|
||||
log.debug('config', 'Revalidating config after coalescing variables');
|
||||
const out = partialHeadplaneConfig(config);
|
||||
if (out instanceof type.errors) {
|
||||
log.error('config', 'Error parsing variables:');
|
||||
for (const [number, error] of out.entries()) {
|
||||
log.error('config', ` - (${number}): ${error.toString()}`);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
type DeepPartial<T> =
|
||||
| {
|
||||
[P in keyof T]?: DeepPartial<T[P]>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
function deepMerge<T>(target: T, source: DeepPartial<T>): T {
|
||||
if (typeof target !== 'object' || typeof source !== 'object')
|
||||
return source as T;
|
||||
const result = { ...target } as T;
|
||||
|
||||
for (const key in source) {
|
||||
const val = source[key];
|
||||
if (val === undefined || val === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof val === 'object') {
|
||||
result[key] = deepMerge(result[key], val);
|
||||
continue;
|
||||
}
|
||||
|
||||
result[key] = val;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
import { type } from 'arktype';
|
||||
|
||||
const stringToBool = type('string | boolean').pipe((v) => {
|
||||
if (typeof v === 'string') {
|
||||
if (v === '1' || v === 'true' || v === 'yes') {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (v === '0' || v === 'false' || v === 'no') {
|
||||
return false;
|
||||
}
|
||||
|
||||
throw new Error(`Invalid string value for boolean: ${v}`);
|
||||
}
|
||||
|
||||
return Boolean(v);
|
||||
});
|
||||
|
||||
const serverConfig = type({
|
||||
host: 'string.ip',
|
||||
port: type('string | number.integer').pipe((v) => Number(v)),
|
||||
data_path: 'string = "/var/lib/headplane/"',
|
||||
cookie_secret: '(32 <= string <= 32)?',
|
||||
cookie_secret_path: 'string?',
|
||||
cookie_secure: stringToBool,
|
||||
})
|
||||
.narrow((obj: Record<string, unknown>, ctx: any) => {
|
||||
const hasVal = obj.cookie_secret != null && `${obj.cookie_secret}` !== '';
|
||||
const hasPath =
|
||||
obj.cookie_secret_path != null && obj.cookie_secret_path !== '';
|
||||
if (hasVal && hasPath)
|
||||
return ctx.reject(
|
||||
`Only one of "cookie_secret" or "cookie_secret_path" may be set.`,
|
||||
);
|
||||
if (!hasVal && !hasPath)
|
||||
return ctx.reject(
|
||||
`Either "cookie_secret" or "cookie_secret_path" must be provided for cookie_secret.`,
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.onDeepUndeclaredKey('reject');
|
||||
|
||||
const partialServerConfig = type({
|
||||
host: 'string.ip?',
|
||||
port: type('string | number.integer')
|
||||
.pipe((v) => Number(v))
|
||||
.optional(),
|
||||
data_path: 'string = "/var/lib/headplane/"',
|
||||
cookie_secret: '32 <= string <= 32?',
|
||||
cookie_secret_path: 'string?',
|
||||
cookie_secure: stringToBool.optional(),
|
||||
});
|
||||
|
||||
const oidcConfig = type({
|
||||
issuer: 'string.url',
|
||||
client_id: 'string',
|
||||
client_secret: 'string?',
|
||||
client_secret_path: 'string?',
|
||||
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?',
|
||||
headscale_api_key_path: 'string?',
|
||||
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
||||
strict_validation: stringToBool.default(true),
|
||||
scope: 'string = "openid email profile"',
|
||||
extra_params: 'Record<string, string>?',
|
||||
authorization_endpoint: 'string.url?',
|
||||
token_endpoint: 'string.url?',
|
||||
userinfo_endpoint: 'string.url?',
|
||||
})
|
||||
.narrow((obj: Record<string, unknown>, ctx: any) => {
|
||||
const hasVal =
|
||||
obj.headscale_api_key != null && `${obj.headscale_api_key}` !== '';
|
||||
const hasPath =
|
||||
obj.headscale_api_key_path != null && obj.headscale_api_key_path !== '';
|
||||
if (hasVal && hasPath)
|
||||
return ctx.reject(
|
||||
`Only one of "headscale_api_key" or "headscale_api_key_path" may be set.`,
|
||||
);
|
||||
if (!hasVal && !hasPath)
|
||||
return ctx.reject(
|
||||
`Either "headscale_api_key" or "headscale_api_key_path" must be provided.`,
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.onDeepUndeclaredKey('reject');
|
||||
|
||||
const partialOidcConfig = type({
|
||||
issuer: 'string.url?',
|
||||
client_id: 'string?',
|
||||
client_secret: 'string?',
|
||||
client_secret_path: 'string?',
|
||||
token_endpoint_auth_method:
|
||||
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
||||
redirect_uri: 'string.url?',
|
||||
user_storage_file: 'string?',
|
||||
disable_api_key_login: stringToBool.optional(),
|
||||
headscale_api_key: 'string?',
|
||||
headscale_api_key_path: 'string?',
|
||||
profile_picture_source: '("oidc" | "gravatar")?',
|
||||
strict_validation: stringToBool.default(true),
|
||||
scope: 'string?',
|
||||
extra_params: 'Record<string, string>?',
|
||||
authorization_endpoint: 'string.url?',
|
||||
token_endpoint: 'string.url?',
|
||||
userinfo_endpoint: 'string.url?',
|
||||
});
|
||||
|
||||
const headscaleConfig = type({
|
||||
url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)),
|
||||
tls_cert_path: 'string?',
|
||||
public_url: 'string.url?',
|
||||
config_path: 'string?',
|
||||
config_strict: stringToBool,
|
||||
dns_records_path: 'string?',
|
||||
}).onDeepUndeclaredKey('reject');
|
||||
|
||||
const partialHeadscaleConfig = type({
|
||||
url: type('string.url')
|
||||
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
|
||||
.optional(),
|
||||
tls_cert_path: 'string?',
|
||||
public_url: 'string.url?',
|
||||
config_path: 'string?',
|
||||
config_strict: stringToBool.optional(),
|
||||
dns_records_path: 'string?',
|
||||
});
|
||||
|
||||
const agentConfig = type({
|
||||
enabled: stringToBool.default(false),
|
||||
host_name: 'string = "headplane-agent"',
|
||||
pre_authkey: 'string?',
|
||||
pre_authkey_path: 'string?',
|
||||
cache_ttl: 'number.integer = 180000',
|
||||
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
|
||||
executable_path: 'string = "/usr/libexec/headplane/agent"',
|
||||
work_dir: 'string = "/var/lib/headplane/agent"',
|
||||
})
|
||||
.narrow((obj: Record<string, unknown>, ctx: any) => {
|
||||
const hasVal = obj.pre_authkey != null && `${obj.pre_authkey}` !== '';
|
||||
const hasPath = obj.pre_authkey_path != null && obj.pre_authkey_path !== '';
|
||||
if (hasVal && hasPath)
|
||||
return ctx.reject(
|
||||
`Only one of "pre_authkey" or "pre_authkey_path" may be set.`,
|
||||
);
|
||||
if (!hasVal && !hasPath)
|
||||
return ctx.reject(
|
||||
`Either "pre_authkey" or "pre_authkey_path" must be provided.`,
|
||||
);
|
||||
return true;
|
||||
})
|
||||
.onDeepUndeclaredKey('reject');
|
||||
|
||||
const partialAgentConfig = type({
|
||||
enabled: stringToBool.default(false),
|
||||
host_name: 'string = "headplane-agent"',
|
||||
pre_authkey: 'string?',
|
||||
pre_authkey_path: 'string?',
|
||||
cache_ttl: 'number.integer = 180000',
|
||||
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
|
||||
executable_path: 'string = "/usr/libexec/headplane/agent"',
|
||||
work_dir: 'string = "/var/lib/headplane/agent"',
|
||||
});
|
||||
|
||||
const dockerConfig = type({
|
||||
enabled: stringToBool,
|
||||
container_name: 'string = ""',
|
||||
container_label: 'string = "me.tale.headplane.target=headscale"',
|
||||
socket: 'string = "unix:///var/run/docker.sock"',
|
||||
});
|
||||
|
||||
const partialDockerConfig = type({
|
||||
enabled: stringToBool,
|
||||
container_name: 'string | undefined',
|
||||
container_label: 'string | undefined',
|
||||
socket: 'string | undefined',
|
||||
}).partial();
|
||||
|
||||
const kubernetesConfig = type({
|
||||
enabled: stringToBool,
|
||||
pod_name: 'string',
|
||||
validate_manifest: stringToBool,
|
||||
});
|
||||
|
||||
const procConfig = type({
|
||||
enabled: stringToBool,
|
||||
});
|
||||
|
||||
const integrationConfig = type({
|
||||
'docker?': dockerConfig,
|
||||
'kubernetes?': kubernetesConfig,
|
||||
'proc?': procConfig,
|
||||
'agent?': agentConfig,
|
||||
});
|
||||
|
||||
const partialIntegrationConfig = type({
|
||||
'docker?': partialDockerConfig,
|
||||
'kubernetes?': kubernetesConfig.partial(),
|
||||
'proc?': procConfig.partial(),
|
||||
'agent?': partialAgentConfig,
|
||||
}).partial();
|
||||
|
||||
export const headplaneConfig = type({
|
||||
debug: stringToBool,
|
||||
server: serverConfig,
|
||||
'oidc?': oidcConfig,
|
||||
'integration?': integrationConfig,
|
||||
headscale: headscaleConfig,
|
||||
}).onDeepUndeclaredKey('delete');
|
||||
|
||||
export const partialHeadplaneConfig = type({
|
||||
debug: stringToBool,
|
||||
server: partialServerConfig,
|
||||
'oidc?': partialOidcConfig,
|
||||
'integration?': partialIntegrationConfig,
|
||||
headscale: partialHeadscaleConfig,
|
||||
}).partial();
|
||||
|
||||
export type HeadplaneConfig = typeof headplaneConfig.infer;
|
||||
export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer;
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Traversal } from 'arktype';
|
||||
import log from '~/utils/log';
|
||||
|
||||
export function deprecatedField() {
|
||||
return (_: unknown, ctx: Traversal) => {
|
||||
log.warn('config', `${ctx.propString} is deprecated and has no effect.`);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
+5
-10
@@ -1,14 +1,12 @@
|
||||
import { eq, isNotNull } from 'drizzle-orm';
|
||||
import { LoaderFunctionArgs } from 'react-router';
|
||||
import { Machine } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import { LoadContext } from '..';
|
||||
import type { Route } from '../../layouts/+types/dashboard';
|
||||
import { ephemeralNodes } from './schema';
|
||||
|
||||
export async function pruneEphemeralNodes({
|
||||
context,
|
||||
request,
|
||||
}: LoaderFunctionArgs<LoadContext>) {
|
||||
}: Route.LoaderArgs) {
|
||||
const session = await context.sessions.auth(request);
|
||||
const ephemerals = await context.db
|
||||
.select()
|
||||
@@ -20,11 +18,8 @@ export async function pruneEphemeralNodes({
|
||||
return;
|
||||
}
|
||||
|
||||
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
|
||||
'v1/node',
|
||||
session.api_key,
|
||||
);
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(session.api_key);
|
||||
const nodes = await api.getNodes();
|
||||
const toPrune = nodes.filter((node) => {
|
||||
if (node.online) {
|
||||
return false;
|
||||
@@ -42,7 +37,7 @@ export async function pruneEphemeralNodes({
|
||||
const promises = toPrune.map((node) => {
|
||||
return async () => {
|
||||
log.debug('api', `Pruning node ${node.name}`);
|
||||
await context.client.delete(`v1/node/${node.id}`, session.api_key);
|
||||
await api.deleteNode(node.id);
|
||||
|
||||
await context.db
|
||||
.delete(ephemeralNodes)
|
||||
|
||||
@@ -1,263 +0,0 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { data } from 'react-router';
|
||||
import { Agent, Dispatcher, request } from 'undici';
|
||||
import { errors } from 'undici';
|
||||
import log from '~/utils/log';
|
||||
import ResponseError from './api-error';
|
||||
|
||||
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
|
||||
const keys = Object.keys(error as Record<string, unknown>);
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
keys.includes('code') &&
|
||||
keys.includes('errno')
|
||||
);
|
||||
}
|
||||
|
||||
function friendlyError(givenError: unknown) {
|
||||
let error: unknown = givenError;
|
||||
if (error instanceof AggregateError) {
|
||||
error = error.errors[0];
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case error instanceof errors.BodyTimeoutError:
|
||||
case error instanceof errors.ConnectTimeoutError:
|
||||
case error instanceof errors.HeadersTimeoutError:
|
||||
return data('Timed out waiting for a response from the Headscale API', {
|
||||
statusText: 'Request Timeout',
|
||||
status: 408,
|
||||
});
|
||||
|
||||
case error instanceof errors.SocketError:
|
||||
case error instanceof errors.SecureProxyConnectionError:
|
||||
case error instanceof errors.ClientClosedError:
|
||||
case error instanceof errors.ClientDestroyedError:
|
||||
case error instanceof errors.RequestAbortedError:
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
|
||||
case error instanceof errors.InvalidArgumentError:
|
||||
case error instanceof errors.InvalidReturnValueError:
|
||||
case error instanceof errors.NotSupportedError:
|
||||
return data('Unable to make a request (this is most likely a bug)', {
|
||||
statusText: 'Internal Server Error',
|
||||
status: 500,
|
||||
});
|
||||
|
||||
case error instanceof errors.HeadersOverflowError:
|
||||
case error instanceof errors.RequestContentLengthMismatchError:
|
||||
case error instanceof errors.ResponseContentLengthMismatchError:
|
||||
case error instanceof errors.ResponseExceededMaxSizeError:
|
||||
return data('The Headscale API returned a malformed response', {
|
||||
statusText: 'Bad Gateway',
|
||||
status: 502,
|
||||
});
|
||||
|
||||
case isNodeNetworkError(error):
|
||||
if (error.code === 'ECONNREFUSED') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ENOTFOUND') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'EAI_AGAIN') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ETIMEDOUT') {
|
||||
return data('Timed out waiting for a response from the Headscale API', {
|
||||
statusText: 'Request Timeout',
|
||||
status: 408,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ECONNRESET') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'EPIPE') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ENETUNREACH') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
if (error.code === 'ENETRESET') {
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
|
||||
return data('The Headscale API is not reachable', {
|
||||
statusText: 'Service Unavailable',
|
||||
status: 503,
|
||||
});
|
||||
|
||||
default:
|
||||
return data((error as Error).message ?? 'An unknown error occurred', {
|
||||
statusText: 'Internal Server Error',
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
try {
|
||||
const res = await request(new URL(url, this.base), {
|
||||
dispatcher: this.agent,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': `Headplane/${__VERSION__}`,
|
||||
},
|
||||
body: options?.body,
|
||||
method,
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error: unknown) {
|
||||
throw friendlyError(error);
|
||||
}
|
||||
}
|
||||
|
||||
async healthcheck() {
|
||||
try {
|
||||
const res = await request(new URL('/health', this.base), {
|
||||
dispatcher: this.agent,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'User-Agent': `Headplane/${__VERSION__}`,
|
||||
},
|
||||
});
|
||||
|
||||
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>;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { Key } from '~/types';
|
||||
import { defineApiEndpoints } from '../factory';
|
||||
|
||||
export interface ApiKeyEndpoints {
|
||||
/**
|
||||
* Retrieves all API keys from the Headscale instance.
|
||||
*
|
||||
* @returns An array of `Key` objects representing the API keys.
|
||||
*/
|
||||
getApiKeys(): Promise<Key[]>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<ApiKeyEndpoints>((client, apiKey) => ({
|
||||
getApiKeys: async () => {
|
||||
const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>(
|
||||
'GET',
|
||||
'v1/apikey',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
return apiKeys;
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
composeEndpoints,
|
||||
defineApiEndpoints,
|
||||
type ExtractApiEndpoints,
|
||||
type UnionToIntersection,
|
||||
} from '../factory';
|
||||
import type { HeadscaleApiInterface } from '../index';
|
||||
import apiKeyEndpoints from './api-keys';
|
||||
import nodeEndpoints from './nodes';
|
||||
import policyEndpoints from './policy';
|
||||
import preAuthKeyEndpoints from './pre-auth-keys';
|
||||
import userEndpoints from './users';
|
||||
|
||||
interface HealthcheckEndpoint {
|
||||
/**
|
||||
* Checks if the Headscale instance is healthy.
|
||||
*
|
||||
* @returns A boolean indicating if the instance is healthy.
|
||||
*/
|
||||
isHealthy(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const healthcheckEndpoint = defineApiEndpoints<HealthcheckEndpoint>(
|
||||
(client, apiKey) => ({
|
||||
isHealthy: async () => {
|
||||
try {
|
||||
const res = await client.rawFetch('/health', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
// This doesn't really matter
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
return res.statusCode === 200;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* A constant list of all endpoint groups.
|
||||
* Add new endpoint groups here.
|
||||
*/
|
||||
export const endpointSets = [
|
||||
apiKeyEndpoints,
|
||||
healthcheckEndpoint,
|
||||
nodeEndpoints,
|
||||
policyEndpoints,
|
||||
preAuthKeyEndpoints,
|
||||
userEndpoints,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* All of the available API methods when interacting with Headscale's API.
|
||||
* We have wrapped each operation with nice methods and parameters to make it
|
||||
* easier to do integration testing by spinning up an actual Headscale instance
|
||||
* and calling these methods against it.
|
||||
*
|
||||
* We also have the benefit of supporting multiple Headscale versions by
|
||||
* passing in different internal implementations based on the OpenAPI spec.
|
||||
*/
|
||||
export type RuntimeApiClient = UnionToIntersection<
|
||||
ExtractApiEndpoints<(typeof endpointSets)[number]>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Composes all endpoint groups into a single runtime API client.
|
||||
*
|
||||
* @param client - The client helpers for making API requests.
|
||||
* @param apiKey - The API key for authentication.
|
||||
* @returns A fully composed runtime API client.
|
||||
*/
|
||||
export default (
|
||||
client: HeadscaleApiInterface['clientHelpers'],
|
||||
apiKey: string,
|
||||
) => composeEndpoints(endpointSets, client, apiKey);
|
||||
@@ -0,0 +1,150 @@
|
||||
import type { Machine } from '~/types';
|
||||
import { defineApiEndpoints } from '../factory';
|
||||
|
||||
export interface NodeEndpoints {
|
||||
/**
|
||||
* Retrieves all nodes (machines) from the Headscale instance.
|
||||
*
|
||||
* @returns An array of `Machine` objects representing the nodes.
|
||||
*/
|
||||
getNodes(): Promise<Machine[]>;
|
||||
|
||||
/**
|
||||
* Retrieves a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to retrieve.
|
||||
* @returns A `Machine` object representing the node.
|
||||
*/
|
||||
getNode(id: string): Promise<Machine>;
|
||||
|
||||
/**
|
||||
* Deletes a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to delete.
|
||||
*/
|
||||
deleteNode(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Registers a new node (machine) with the given user and key.
|
||||
*
|
||||
* @param user The user to associate with the node.
|
||||
* @param key The registration key for the node.
|
||||
* @returns A `Machine` object representing the newly registered node.
|
||||
*/
|
||||
registerNode(user: string, key: string): Promise<Machine>;
|
||||
|
||||
/**
|
||||
* Approves routes for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param routes An array of routes to approve for the node.
|
||||
*/
|
||||
approveNodeRoutes(id: string, routes: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Expires a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to expire.
|
||||
*/
|
||||
expireNode(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Renames a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to rename.
|
||||
* @param newName The new name for the node.
|
||||
*/
|
||||
renameNode(id: string, newName: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets tags for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param tags An array of tags to set for the node.
|
||||
*/
|
||||
setNodeTags(id: string, tags: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets the user for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param user The user to set for the node.
|
||||
*/
|
||||
setNodeUser(id: string, user: string): Promise<void>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
|
||||
getNodes: async () => {
|
||||
const { nodes } = await client.apiFetch<{ nodes: Machine[] }>(
|
||||
'GET',
|
||||
'v1/node',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
return nodes;
|
||||
},
|
||||
|
||||
getNode: async (nodeId) => {
|
||||
const { node } = await client.apiFetch<{ node: Machine }>(
|
||||
'GET',
|
||||
`v1/node/${nodeId}`,
|
||||
apiKey,
|
||||
);
|
||||
|
||||
return node;
|
||||
},
|
||||
|
||||
deleteNode: async (nodeId) => {
|
||||
await client.apiFetch<void>('DELETE', `v1/node/${nodeId}`, apiKey);
|
||||
},
|
||||
|
||||
registerNode: async (user, key) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.append('user', user);
|
||||
qp.append('key', key);
|
||||
const { node } = await client.apiFetch<{ node: Machine }>(
|
||||
'POST',
|
||||
`v1/node/register?${qp.toString()}`,
|
||||
apiKey,
|
||||
{
|
||||
user,
|
||||
key,
|
||||
},
|
||||
);
|
||||
|
||||
return node;
|
||||
},
|
||||
|
||||
approveNodeRoutes: async (nodeId, routes) => {
|
||||
await client.apiFetch<void>(
|
||||
'POST',
|
||||
`v1/node/${nodeId}/approve_routes`,
|
||||
apiKey,
|
||||
{ routes },
|
||||
);
|
||||
},
|
||||
|
||||
expireNode: async (nodeId) => {
|
||||
await client.apiFetch<void>('POST', `v1/node/${nodeId}/expire`, apiKey);
|
||||
},
|
||||
|
||||
renameNode: async (nodeId, newName) => {
|
||||
await client.apiFetch<void>(
|
||||
'POST',
|
||||
`v1/node/${nodeId}/rename/${newName}`,
|
||||
apiKey,
|
||||
);
|
||||
},
|
||||
|
||||
setNodeTags: async (nodeId, tags) => {
|
||||
await client.apiFetch<void>('POST', `v1/node/${nodeId}/tags`, apiKey, {
|
||||
tags,
|
||||
});
|
||||
},
|
||||
|
||||
setNodeUser: async (nodeId, user) => {
|
||||
await client.apiFetch<void>('POST', `v1/node/${nodeId}/user`, apiKey, {
|
||||
user,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,41 @@
|
||||
import { defineApiEndpoints } from '../factory';
|
||||
|
||||
export interface PolicyEndpoints {
|
||||
/**
|
||||
* Retrieves the current ACL policy from the Headscale instance.
|
||||
*
|
||||
* @returns The ACL policy as a string and the date it was last updated.
|
||||
*/
|
||||
getPolicy(): Promise<{ policy: string; updatedAt: Date | null }>;
|
||||
|
||||
/**
|
||||
* Sets the ACL policy for the Headscale instance.
|
||||
*
|
||||
* @param policy The ACL policy as a string.
|
||||
* @returns The updated ACL policy as a string and the date it was last updated.
|
||||
*/
|
||||
setPolicy(policy: string): Promise<{ policy: string; updatedAt: Date }>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<PolicyEndpoints>((client, apiKey) => ({
|
||||
getPolicy: async () => {
|
||||
const { policy, updatedAt } = await client.apiFetch<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>('GET', 'v1/policy', apiKey);
|
||||
|
||||
return {
|
||||
policy,
|
||||
updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
|
||||
};
|
||||
},
|
||||
|
||||
setPolicy: async (policy) => {
|
||||
const { policy: newPolicy, updatedAt } = await client.apiFetch<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>('PUT', 'v1/policy', apiKey, { policy });
|
||||
|
||||
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { PreAuthKey } from '~/types';
|
||||
import { defineApiEndpoints } from '../factory';
|
||||
|
||||
export interface PreAuthKeyEndpoints {
|
||||
/**
|
||||
* Retrieves all pre-authentication keys for a specific user.
|
||||
*
|
||||
* @param user The user to retrieve pre-authentication keys for.
|
||||
* @returns An array of `PreAuthKey` objects representing the pre-authentication keys.
|
||||
*/
|
||||
getPreAuthKeys(user: string): Promise<PreAuthKey[]>;
|
||||
|
||||
/**
|
||||
* Creates a new pre-authentication key for a specific user.
|
||||
*
|
||||
* @param user The user to create the pre-authentication key for.
|
||||
* @param ephemeral Whether the key is ephemeral.
|
||||
* @param reusable Whether the key is reusable.
|
||||
* @param expiration The expiration date of the key, or `null` for no expiration.
|
||||
* @param aclTags An array of ACL tags to associate with the key, or `null` for none.
|
||||
* @returns A `PreAuthKey` object representing the newly created pre-authentication key.
|
||||
*/
|
||||
createPreAuthKey(
|
||||
user: string,
|
||||
ephemeral: boolean,
|
||||
reusable: boolean,
|
||||
expiration: Date | null,
|
||||
aclTags: string[] | null,
|
||||
): Promise<PreAuthKey>;
|
||||
|
||||
/**
|
||||
* Expires a specific pre-authentication key for a user.
|
||||
*
|
||||
* @param user The user associated with the pre-authentication key.
|
||||
* @param key The pre-authentication key to expire.
|
||||
*/
|
||||
expirePreAuthKey(user: string, key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
|
||||
getPreAuthKeys: async (user) => {
|
||||
const { preAuthKeys } = await client.apiFetch<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>('GET', 'v1/preauthkey', apiKey, { user });
|
||||
|
||||
return preAuthKeys;
|
||||
},
|
||||
|
||||
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
|
||||
const { preAuthKey } = await client.apiFetch<{
|
||||
preAuthKey: PreAuthKey;
|
||||
}>('POST', 'v1/preauthkey', apiKey, {
|
||||
user,
|
||||
ephemeral,
|
||||
reusable,
|
||||
expiration: expiration ? expiration.toISOString() : null,
|
||||
aclTags,
|
||||
});
|
||||
|
||||
return preAuthKey;
|
||||
},
|
||||
|
||||
expirePreAuthKey: async (user, key) => {
|
||||
await client.apiFetch<void>('POST', 'v1/preauthkey/expire', apiKey, {
|
||||
user,
|
||||
key,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { User } from '~/types';
|
||||
import { defineApiEndpoints } from '../factory';
|
||||
|
||||
export interface UserEndpoints {
|
||||
/**
|
||||
* Retrieves users from the Headscale instance, optionally filtering by ID, name, or email.
|
||||
*
|
||||
* @param id Optional ID of the user to retrieve.
|
||||
* @param name Optional name of the user to retrieve.
|
||||
* @param email Optional email of the user to retrieve.
|
||||
* @returns An array of `User` objects representing the users.
|
||||
*/
|
||||
getUsers(id?: string, name?: string, email?: string): Promise<User[]>;
|
||||
|
||||
/**
|
||||
* Creates a new user in the Headscale instance.
|
||||
*
|
||||
* @param username The username of the new user.
|
||||
* @param email Optional email of the new user.
|
||||
* @param displayName Optional display name of the new user.
|
||||
* @param pictureUrl Optional picture URL of the new user.
|
||||
* @returns A `User` object representing the newly created user.
|
||||
*/
|
||||
createUser(
|
||||
username: string,
|
||||
email?: string,
|
||||
displayName?: string,
|
||||
pictureUrl?: string,
|
||||
): Promise<User>;
|
||||
|
||||
/**
|
||||
* Deletes a specific user by its ID.
|
||||
*
|
||||
* @param id The ID of the user to delete.
|
||||
*/
|
||||
deleteUser(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Renames a specific user by its ID.
|
||||
*
|
||||
* @param id The ID of the user to rename.
|
||||
* @param newName The new name for the user.
|
||||
*/
|
||||
renameUser(id: string, newName: string): Promise<void>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<UserEndpoints>((client, apiKey) => ({
|
||||
getUsers: async (id, name, email) => {
|
||||
const moreThanOneFilter =
|
||||
[id, name, email].filter((v) => v !== undefined).length > 1;
|
||||
|
||||
if (moreThanOneFilter) {
|
||||
throw new Error('Only one of id, name, or email filters can be provided');
|
||||
}
|
||||
|
||||
const { users } = await client.apiFetch<{ users: User[] }>(
|
||||
'GET',
|
||||
'v1/user',
|
||||
apiKey,
|
||||
{ id, name, email },
|
||||
);
|
||||
|
||||
return users;
|
||||
},
|
||||
|
||||
createUser: async (username, email, displayName, pictureUrl) => {
|
||||
const { user } = await client.apiFetch<{ user: User }>(
|
||||
'POST',
|
||||
'v1/user',
|
||||
apiKey,
|
||||
{ name: username, email, displayName, pictureUrl },
|
||||
);
|
||||
|
||||
return user;
|
||||
},
|
||||
|
||||
deleteUser: async (id) => {
|
||||
await client.apiFetch<void>('DELETE', `v1/user/${id}`, apiKey);
|
||||
},
|
||||
|
||||
renameUser: async (oldId, newName) => {
|
||||
await client.apiFetch<void>(
|
||||
'POST',
|
||||
`v1/user/${oldId}/rename/${newName}`,
|
||||
apiKey,
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { HeadscaleConnectionError } from './error';
|
||||
|
||||
/**
|
||||
* Represents an error returned by the Headscale API.
|
||||
*/
|
||||
export interface HeadscaleAPIError {
|
||||
requestUrl: `${string} ${string}`;
|
||||
statusCode: number;
|
||||
rawData: string;
|
||||
data: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is a HeadscaleAPIError.
|
||||
* @param error - The error to check.
|
||||
* @returns True if the error is a HeadscaleAPIError, false otherwise.
|
||||
*/
|
||||
export function isApiError(error: unknown): error is HeadscaleAPIError {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'requestUrl' in error &&
|
||||
'statusCode' in error &&
|
||||
'rawData' in error &&
|
||||
'data' in error
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if an error is a HeadscaleConnectionError.
|
||||
* @param error - The error to check.
|
||||
* @returns True if the error is a HeadscaleConnectionError, false otherwise.
|
||||
*/
|
||||
export function isConnectionError(
|
||||
error: unknown,
|
||||
): error is HeadscaleConnectionError {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'requestUrl' in error &&
|
||||
'errorCode' in error &&
|
||||
'errorMessage' in error &&
|
||||
'extraData' in error
|
||||
);
|
||||
}
|
||||
|
||||
export function isDataUnauthorizedError(error: unknown): boolean {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'data' in error &&
|
||||
typeof error.data === 'object' &&
|
||||
error.data != null &&
|
||||
'statusCode' in error.data &&
|
||||
error.data.statusCode === 401
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { errors } from 'undici';
|
||||
|
||||
/**
|
||||
* Helper function that determines if an error is a Node.js exception
|
||||
* @param - The error to check
|
||||
* @returns True if the error is a Node.js exception, false otherwise
|
||||
*/
|
||||
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
'errno' in error
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A friendly error representation for Headscale connection issues.
|
||||
*/
|
||||
export interface HeadscaleConnectionError {
|
||||
requestUrl: string;
|
||||
errorCode: string;
|
||||
errorMessage: string;
|
||||
extraData: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an Undici error into a friendly HeadscaleAPIError.
|
||||
* This is used to avoid exposing rough error edges to the user.
|
||||
*
|
||||
* @param error - The Undici error to convert.
|
||||
* @param requestUrl - The URL of the request that caused the error.
|
||||
* @returns A friendly HeadscaleAPIError.
|
||||
*/
|
||||
export function undiciToFriendlyError(
|
||||
error: unknown,
|
||||
requestUrl: string,
|
||||
): HeadscaleConnectionError {
|
||||
// MARK: Do we need to go deeper into causes here?
|
||||
if (error instanceof AggregateError) {
|
||||
error = error.errors[0];
|
||||
}
|
||||
|
||||
if (error instanceof errors.UndiciError) {
|
||||
return {
|
||||
requestUrl,
|
||||
errorCode: error.code,
|
||||
errorMessage: error.message,
|
||||
extraData: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isNodeNetworkError(error)) {
|
||||
return {
|
||||
requestUrl,
|
||||
errorCode: error.code ?? 'UNKNOWN_NODE_NETWORK_ERROR',
|
||||
errorMessage: error.message,
|
||||
extraData: {
|
||||
syscall: error.syscall,
|
||||
path: error.path,
|
||||
errno: error.errno,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requestUrl,
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
errorMessage: 'An unknown error occured',
|
||||
extraData: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { HeadscaleApiInterface } from '../api';
|
||||
|
||||
/**
|
||||
* Creates a strongly-typed group factory for a given endpoint interface.
|
||||
*
|
||||
* Example:
|
||||
* export const apiKeyGroup = defineGroup<ApiKeyEndpoints>({...})
|
||||
*/
|
||||
|
||||
export interface EndpointFactory<T extends object> {
|
||||
__type?: T;
|
||||
(client: HeadscaleApiInterface['clientHelpers'], apiKey: string): T;
|
||||
}
|
||||
|
||||
export function defineApiEndpoints<T extends object>(
|
||||
factories: (
|
||||
client: HeadscaleApiInterface['clientHelpers'],
|
||||
apiKey: string,
|
||||
) => T,
|
||||
): EndpointFactory<T> {
|
||||
return factories;
|
||||
}
|
||||
|
||||
export type ExtractApiEndpoints<F> = F extends EndpointFactory<infer T>
|
||||
? T
|
||||
: never;
|
||||
export type UnionToIntersection<U> = (
|
||||
U extends any
|
||||
? (k: U) => void
|
||||
: never
|
||||
) extends (k: infer I) => void
|
||||
? I
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Compose multiple endpoint sets into a single typed runtime client
|
||||
*/
|
||||
export function composeEndpoints<T extends readonly EndpointFactory<any>[]>(
|
||||
factories: T,
|
||||
clientHelpers: any,
|
||||
apiKey: string,
|
||||
): UnionToIntersection<ExtractApiEndpoints<T[number]>> {
|
||||
const instances = factories.map((f) => f(clientHelpers, apiKey));
|
||||
return Object.assign({}, ...instances) as any;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { dereference } from '@readme/openapi-parser';
|
||||
import { OpenAPIV2 } from 'openapi-types';
|
||||
|
||||
/**
|
||||
* A map of operation IDs to their hashes.
|
||||
*/
|
||||
export interface DocumentHash {
|
||||
[operationId: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an OpenAPI v2 document, generate a map of operations with hashes.
|
||||
* This gives us deterministic identifers to determine the version of Headscale
|
||||
* that is being used at runtime.
|
||||
*
|
||||
* @param doc The OpenAPI v2 document to hash.
|
||||
* @returns A map of operation IDs to their hashes.
|
||||
*/
|
||||
export async function hashOpenApiDocument(
|
||||
doc: OpenAPIV2.Document,
|
||||
): Promise<DocumentHash> {
|
||||
const spec = await dereference(doc);
|
||||
const hashes: DocumentHash = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [path, item] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
if (typeof operation !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
|
||||
const raw = JSON.stringify(
|
||||
{
|
||||
path,
|
||||
method: method.toUpperCase(),
|
||||
parameters,
|
||||
responses,
|
||||
},
|
||||
Object.keys({ path, method, parameters, responses }).sort(),
|
||||
);
|
||||
|
||||
const hash = createHash('md5').update(raw).digest('hex').slice(0, 16);
|
||||
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
|
||||
seen.add(final);
|
||||
hashes[`${method.toUpperCase()} ${path}`] = final;
|
||||
}
|
||||
}
|
||||
|
||||
return hashes;
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { dereference } from '@readme/openapi-parser';
|
||||
import type { OpenAPIV2 } from 'openapi-types';
|
||||
import { data } from 'react-router';
|
||||
import { Agent, type Dispatcher, request } from 'undici';
|
||||
import log from '~/utils/log';
|
||||
import endpointSets, { RuntimeApiClient } from './endpoints';
|
||||
import { undiciToFriendlyError } from './error';
|
||||
import { HeadscaleAPIError, isApiError } from './error-client';
|
||||
import { detectApiVersion, isAtLeast, type Version } from './version';
|
||||
|
||||
/**
|
||||
* A low-level composed interface for interacting with the Headscale API.
|
||||
* This interface provides direct access to the underlying Undici agent
|
||||
* and methods for making API requests.
|
||||
*
|
||||
* It is also responsible for handling OpenAPI spec polling and hashing to
|
||||
* determine the implementations of API methods when requested for use.
|
||||
*/
|
||||
export interface HeadscaleApiInterface {
|
||||
/**
|
||||
* The underlying Undici agent used for making requests.
|
||||
*/
|
||||
undiciAgent: Agent;
|
||||
|
||||
/**
|
||||
* The base URL of the Headscale API.
|
||||
*/
|
||||
baseUrl: string;
|
||||
|
||||
/**
|
||||
* The OpenAPI hashes retrieved from the Headscale instance at runtime.
|
||||
* This is used to determine which implementations of API methods to use.
|
||||
*/
|
||||
openapiHashes: Record<string, string> | null;
|
||||
|
||||
/**
|
||||
* The detected API version of the connected Headscale instance.
|
||||
*/
|
||||
apiVersion: Version;
|
||||
|
||||
/**
|
||||
* Retrieves a runtime API client for the given API key.
|
||||
*
|
||||
* @param apiKey The API key to use for authentication.
|
||||
* @returns A `RuntimeApiClient` instance for interacting with the API.
|
||||
*/
|
||||
getRuntimeClient(apiKey: string): RuntimeApiClient;
|
||||
|
||||
/**
|
||||
* A set of helper methods made available to API method implementations.
|
||||
* The idea is to make interacting with the API easier by providing
|
||||
* common functionality that can be reused across multiple methods.
|
||||
*/
|
||||
clientHelpers: {
|
||||
/**
|
||||
* Checks if the connected Headscale instance's API version
|
||||
* is at least the specified version.
|
||||
*
|
||||
* @param version The version to check against.
|
||||
* @returns `true` if the API version is at least the specified version, `false` otherwise.
|
||||
*/
|
||||
isAtleast(version: Version): boolean;
|
||||
|
||||
/**
|
||||
* Makes a raw fetch request to the Headscale API via the Undici agent.
|
||||
* This method is used internally by API method implementations
|
||||
* to make requests to the Headscale API.
|
||||
*
|
||||
* @param path The API path to request.
|
||||
* @param options Optional request options.
|
||||
* @returns A promise that resolves to the response data.
|
||||
*/
|
||||
rawFetch(
|
||||
path: string,
|
||||
options?: Partial<Dispatcher.RequestOptions>,
|
||||
): Promise<Dispatcher.ResponseData>;
|
||||
|
||||
/**
|
||||
* Makes a typed API fetch request to the Headscale API.
|
||||
* This method is used internally by API method implementations
|
||||
* to make requests to the Headscale API and parse the response.
|
||||
*
|
||||
* @param method The HTTP method to use.
|
||||
* @param apiPath The API path to request.
|
||||
* @param apiKey The API key to use for authentication.
|
||||
* @param bodyOrQuery Optional body or query parameters.
|
||||
* @returns A promise that resolves to the typed response data.
|
||||
*/
|
||||
apiFetch<T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
|
||||
apiPath: `v1/${string}`,
|
||||
apiKey: string,
|
||||
bodyOrQuery?: Record<string, unknown>,
|
||||
): Promise<T>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Headscale API client interface.
|
||||
*
|
||||
* @param baseUrl The base URL of the Headscale API.
|
||||
* @param certPath Optional path to a custom TLS certificate for secure connections.
|
||||
* @returns A promise that resolves to a `HeadscaleApiClient` instance.
|
||||
*/
|
||||
export async function createHeadscaleInterface(
|
||||
baseUrl: string,
|
||||
certPath?: string,
|
||||
): Promise<HeadscaleApiInterface> {
|
||||
const undiciAgent = await createUndiciAgent(certPath);
|
||||
let openapiHashes: Record<string, string> | null = null;
|
||||
let apiVersion: Version;
|
||||
|
||||
const rawFetch = async (
|
||||
url: string,
|
||||
options?: Partial<Dispatcher.RequestOptions>,
|
||||
): Promise<Dispatcher.ResponseData> => {
|
||||
const method = options?.method ?? 'GET';
|
||||
log.debug('api', '%s %s', method, url);
|
||||
|
||||
try {
|
||||
const res = await request(new URL(url, baseUrl), {
|
||||
dispatcher: undiciAgent,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
Accept: 'application/json',
|
||||
'User-Agent': `Headplane/${__VERSION__}`,
|
||||
},
|
||||
|
||||
body: options?.body,
|
||||
method,
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
|
||||
throw data(errorBody, {
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const apiFetch = async <T>(
|
||||
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
|
||||
apiPath: `v1/${string}`,
|
||||
apiKey: string,
|
||||
bodyOrQuery?: Record<string, unknown>,
|
||||
): Promise<T> => {
|
||||
let url = `/api/${apiPath}`;
|
||||
const options: Partial<Dispatcher.RequestOptions> = {
|
||||
method: method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
};
|
||||
|
||||
if (bodyOrQuery) {
|
||||
if (method === 'GET' || method === 'DELETE') {
|
||||
// Filter out undefined values
|
||||
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(bodyOrQuery)) {
|
||||
if (value !== undefined) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
if ([...params.keys()].length > 0) {
|
||||
url += `?${params.toString()}`;
|
||||
}
|
||||
} else {
|
||||
options.body = JSON.stringify(bodyOrQuery);
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const res = await rawFetch(url, options);
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug(
|
||||
'api',
|
||||
'%s %s failed with status %d',
|
||||
method,
|
||||
apiPath,
|
||||
res.statusCode,
|
||||
);
|
||||
|
||||
const rawData = await res.body.text();
|
||||
const jsonData = (() => {
|
||||
try {
|
||||
return JSON.parse(rawData) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
throw data(
|
||||
{
|
||||
requestUrl: `${method} ${apiPath}`,
|
||||
statusCode: res.statusCode,
|
||||
rawData,
|
||||
data: jsonData,
|
||||
} satisfies HeadscaleAPIError,
|
||||
{
|
||||
status: 502,
|
||||
statusText: 'Bad Gateway',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Polls the OpenAPI spec endpoint and generates operation hashes.
|
||||
* This is used to determine which implementations of API methods to use.
|
||||
*
|
||||
* @returns A promise that resolves to the OpenAPI operation hashes.
|
||||
*/
|
||||
async function fetchAndHashOpenapi(): Promise<Record<string, string> | null> {
|
||||
try {
|
||||
const res = await rawFetch('/swagger/v1/openapiv2.json');
|
||||
if (res.statusCode !== 200) {
|
||||
log.error('api', 'Failed to fetch OpenAPI spec: %d', res.statusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = await res.body.json();
|
||||
const spec = await dereference(body as OpenAPIV2.Document);
|
||||
const hashes = generateSpecHashes(spec);
|
||||
log.debug(
|
||||
'api',
|
||||
'OpenAPI hashes updated (%d endpoints)',
|
||||
Object.keys(hashes).length,
|
||||
);
|
||||
return hashes;
|
||||
} catch (error) {
|
||||
if (isApiError(error)) {
|
||||
log.debug('api', 'Failed to fetch OpenAPI spec: %d', error.statusCode);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const isAtleast = (version: Version): boolean => {
|
||||
return isAtLeast(apiVersion, version);
|
||||
};
|
||||
|
||||
openapiHashes = await fetchAndHashOpenapi();
|
||||
apiVersion = detectApiVersion(openapiHashes);
|
||||
|
||||
setInterval(async () => {
|
||||
const hashes = await fetchAndHashOpenapi();
|
||||
if (hashes) {
|
||||
openapiHashes = hashes;
|
||||
apiVersion = detectApiVersion(openapiHashes);
|
||||
}
|
||||
}, 60_000); // every 60 seconds
|
||||
|
||||
return {
|
||||
undiciAgent,
|
||||
baseUrl,
|
||||
openapiHashes,
|
||||
apiVersion,
|
||||
getRuntimeClient: (apiKey: string) =>
|
||||
endpointSets(
|
||||
{
|
||||
rawFetch,
|
||||
apiFetch,
|
||||
isAtleast,
|
||||
},
|
||||
apiKey,
|
||||
),
|
||||
clientHelpers: {
|
||||
rawFetch,
|
||||
apiFetch,
|
||||
isAtleast,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Undici agent for making HTTP requests.
|
||||
*
|
||||
* @param certPath Optional path to a custom TLS certificate for secure connections.
|
||||
* @returns A promise that resolves to an `Agent` instance.
|
||||
*/
|
||||
async function createUndiciAgent(certPath?: string): Promise<Agent> {
|
||||
if (!certPath) {
|
||||
return new Agent();
|
||||
}
|
||||
|
||||
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 Agent({ connect: { ca: data.trim() } });
|
||||
} catch (error) {
|
||||
log.error('config', 'Failed to load Headscale TLS cert: %s', error);
|
||||
log.debug('config', 'Error Details: %o', error);
|
||||
return new Agent();
|
||||
}
|
||||
}
|
||||
|
||||
function generateSpecHashes(spec: OpenAPIV2.Document) {
|
||||
const hashes: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [path, item] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
if (typeof operation !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
|
||||
const raw = JSON.stringify(
|
||||
{
|
||||
path,
|
||||
method: method.toUpperCase(),
|
||||
parameters,
|
||||
responses,
|
||||
},
|
||||
Object.keys({ path, method, parameters, responses }).sort(),
|
||||
);
|
||||
|
||||
const hash = createHash('md5').update(raw).digest('hex').slice(0, 16);
|
||||
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
|
||||
seen.add(final);
|
||||
hashes[`${method.toUpperCase()} ${path}`] = final;
|
||||
}
|
||||
}
|
||||
|
||||
return hashes;
|
||||
}
|
||||
@@ -3,13 +3,15 @@
|
||||
export default class ResponseError extends Error {
|
||||
status: number;
|
||||
response: string;
|
||||
requestUrl: string;
|
||||
responseObject?: Record<string, unknown>;
|
||||
|
||||
constructor(status: number, response: string) {
|
||||
super(`Response Error (${status}): ${response}`);
|
||||
constructor(status: number, response: string, requestUrl: string) {
|
||||
super(`${requestUrl}: status ${status} - ${response}`);
|
||||
this.name = 'ResponseError';
|
||||
this.status = status;
|
||||
this.response = response;
|
||||
this.requestUrl = requestUrl;
|
||||
|
||||
try {
|
||||
// Try to parse the response as JSON to get a response object
|
||||
@@ -0,0 +1,103 @@
|
||||
import canonicals from '~/openapi-canonical-families.json';
|
||||
import hashes from '~/openapi-operation-hashes.json';
|
||||
import log from '~/utils/log';
|
||||
|
||||
/**
|
||||
* The known API versions based on operation hashes.
|
||||
*/
|
||||
export type Version = keyof typeof hashes;
|
||||
const VERSIONS = Object.keys(hashes) as Version[];
|
||||
|
||||
/**
|
||||
* Detects the closest matching API version using operation hashes.
|
||||
* Falls back to the latest known version and emits a warning if unknown.
|
||||
*
|
||||
* @param observed - A mapping of operation identifiers to their hashes.
|
||||
* @returns The detected API version.
|
||||
*/
|
||||
export function detectApiVersion(
|
||||
observed: Record<string, string> | null,
|
||||
): Version {
|
||||
if (!observed) {
|
||||
const latest = VERSIONS.at(-1)!;
|
||||
log.warn(
|
||||
'api',
|
||||
'No operation hashes observed, defaulting to version %s',
|
||||
latest,
|
||||
);
|
||||
return latest;
|
||||
}
|
||||
|
||||
let bestVersion: Version | null = null;
|
||||
let bestScore = -1;
|
||||
|
||||
for (const [version, known] of Object.entries(hashes) as [
|
||||
Version,
|
||||
Record<string, string>,
|
||||
][]) {
|
||||
let score = 0;
|
||||
for (const [op, hash] of Object.entries(observed)) {
|
||||
if (known[op] === hash) score++;
|
||||
}
|
||||
if (score > bestScore) {
|
||||
bestVersion = version;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
|
||||
if (!bestVersion || bestScore === 0) {
|
||||
const latest = VERSIONS.at(-1)!;
|
||||
log.warn(
|
||||
'api',
|
||||
'Could not determine API version, defaulting to %s',
|
||||
latest,
|
||||
);
|
||||
return latest;
|
||||
}
|
||||
|
||||
if (bestScore < Object.keys(observed).length) {
|
||||
log.warn(
|
||||
'api',
|
||||
'Partial version match: %d/%d endpoints for version %s',
|
||||
bestScore,
|
||||
Object.keys(observed).length,
|
||||
bestVersion,
|
||||
);
|
||||
}
|
||||
|
||||
const canonical = Object.entries(canonicals).find(([_, family]) =>
|
||||
family.includes(bestVersion),
|
||||
)?.[0] as Version | undefined;
|
||||
|
||||
if (!canonical) {
|
||||
log.warn(
|
||||
'api',
|
||||
'Could not canonicalize detected version %s, using as-is',
|
||||
bestVersion,
|
||||
);
|
||||
|
||||
return bestVersion;
|
||||
}
|
||||
|
||||
if (canonical !== bestVersion) {
|
||||
log.info(
|
||||
'api',
|
||||
'Canonicalizing detected version %s → %s (same schema)',
|
||||
bestVersion,
|
||||
canonical,
|
||||
);
|
||||
}
|
||||
|
||||
return canonical;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current version is at least the baseline version.
|
||||
*
|
||||
* @param current - The current API version.
|
||||
* @param baseline - The baseline API version to compare against.
|
||||
* @returns True if current is at least baseline, false otherwise.
|
||||
*/
|
||||
export function isAtLeast(current: Version, baseline: Version) {
|
||||
return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { constants, access, readFile, writeFile } from 'node:fs/promises';
|
||||
import { access, constants, readFile, writeFile } from 'node:fs/promises';
|
||||
import { setTimeout } from 'node:timers/promises';
|
||||
import log from '~/utils/log';
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ const databaseConfig = type({
|
||||
type: '"sqlite" | "sqlite3"',
|
||||
sqlite: {
|
||||
path: 'string',
|
||||
write_head_log: goBool.default(true),
|
||||
write_ahead_log: goBool.default(true),
|
||||
wal_autocheckpoint: 'number = 1000',
|
||||
},
|
||||
})
|
||||
|
||||
@@ -7,7 +7,7 @@ import { inArray } from 'drizzle-orm';
|
||||
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver-core';
|
||||
import { HostInfo } from '~/types';
|
||||
import log from '~/utils/log';
|
||||
import { HeadplaneConfig } from './config/schema';
|
||||
import { HeadplaneConfig } from './config/config-schema';
|
||||
import { hostInfo } from './db/schema';
|
||||
|
||||
export async function createHeadplaneAgent(
|
||||
|
||||
+53
-20
@@ -1,15 +1,13 @@
|
||||
import { join } from 'node:path';
|
||||
import { env, versions } from 'node:process';
|
||||
import { exit, versions } from 'node:process';
|
||||
import { createHonoServer } from 'react-router-hono-server/node';
|
||||
import log from '~/utils/log';
|
||||
import { configureConfig, configureLogger, envVariables } from './config/env';
|
||||
import { loadIntegration } from './config/integration';
|
||||
import { loadConfig } from './config/loader';
|
||||
import { loadConfig } from './config/load';
|
||||
import { createDbClient } from './db/client.server';
|
||||
import { createApiClient } from './headscale/api-client';
|
||||
import { createHeadscaleInterface } from './headscale/api';
|
||||
import { loadHeadscaleConfig } from './headscale/config-loader';
|
||||
import { createHeadplaneAgent } from './hp-agent';
|
||||
import { configureOidcAuth } from './web/oidc';
|
||||
import { createSessionStorage } from './web/sessions';
|
||||
|
||||
declare global {
|
||||
@@ -21,13 +19,17 @@ declare global {
|
||||
// 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],
|
||||
}),
|
||||
);
|
||||
let config: HeadplaneConfig;
|
||||
|
||||
try {
|
||||
config = await loadConfig();
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
log.error('server', 'Unable to load configuration: %s', error.message);
|
||||
}
|
||||
|
||||
exit(1);
|
||||
}
|
||||
|
||||
const db = await createDbClient(join(config.server.data_path, 'hp_persist.db'));
|
||||
const agents = await createHeadplaneAgent(
|
||||
@@ -36,10 +38,25 @@ const agents = await createHeadplaneAgent(
|
||||
db,
|
||||
);
|
||||
|
||||
const hsApi = await createHeadscaleInterface(
|
||||
config.headscale.url,
|
||||
config.headscale.tls_cert_path,
|
||||
);
|
||||
|
||||
// We also use this file to load anything needed by the react router code.
|
||||
// These are usually per-request things that we need access to, like the
|
||||
// helper that can issue and revoke cookies.
|
||||
export type LoadContext = typeof appLoadContext;
|
||||
|
||||
import 'react-router';
|
||||
import { HeadplaneConfig } from './config/config-schema';
|
||||
import { ConfigError } from './config/error';
|
||||
import { createOidcConnector } from './web/oidc-connector';
|
||||
|
||||
declare module 'react-router' {
|
||||
interface AppLoadContext extends LoadContext {}
|
||||
}
|
||||
|
||||
const appLoadContext = {
|
||||
config,
|
||||
hs: await loadHeadscaleConfig(
|
||||
@@ -56,19 +73,20 @@ const appLoadContext = {
|
||||
cookie: {
|
||||
name: '_hp_auth',
|
||||
secure: config.server.cookie_secure,
|
||||
maxAge: 60 * 60 * 24, // 24 hours
|
||||
// domain: config.server.cookie_domain,
|
||||
maxAge: config.server.cookie_max_age,
|
||||
domain: config.server.cookie_domain,
|
||||
},
|
||||
}),
|
||||
|
||||
client: await createApiClient(
|
||||
config.headscale.url,
|
||||
config.headscale.tls_cert_path,
|
||||
),
|
||||
|
||||
hsApi,
|
||||
agents,
|
||||
integration: await loadIntegration(config.integration),
|
||||
oidc: config.oidc ? await configureOidcAuth(config.oidc) : undefined,
|
||||
oidcConnector: config.oidc
|
||||
? await createOidcConnector(
|
||||
config.oidc,
|
||||
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
|
||||
)
|
||||
: undefined,
|
||||
db,
|
||||
};
|
||||
|
||||
@@ -86,6 +104,11 @@ export default createHonoServer({
|
||||
});
|
||||
},
|
||||
serveStaticOptions: {
|
||||
publicAssets: {
|
||||
// This is part of our monkey-patch for react-router-hono-server
|
||||
// To see the first part, go to the patches/ directory.
|
||||
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ''),
|
||||
},
|
||||
clientAssets: {
|
||||
// This is part of our monkey-patch for react-router-hono-server
|
||||
// To see the first part, go to the patches/ directory.
|
||||
@@ -106,3 +129,13 @@ export default createHonoServer({
|
||||
log.info('server', 'Running on %s:%s', info.address, info.port);
|
||||
},
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
log.info('server', 'Received SIGINT, shutting down...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', () => {
|
||||
log.info('server', 'Received SIGTERM, shutting down...');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
import * as oidc from 'openid-client';
|
||||
import log from '~/utils/log';
|
||||
import type { HeadplaneConfig } from '../config/config-schema';
|
||||
import type { RuntimeApiClient } from '../headscale/api/endpoints';
|
||||
import { isDataUnauthorizedError } from '../headscale/api/error-client';
|
||||
|
||||
export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
|
||||
|
||||
/**
|
||||
* Errors that can occur during OIDC connector setup and validation.
|
||||
*/
|
||||
export type OidcConnectorError =
|
||||
| 'INVALID_API_KEY'
|
||||
| 'MISSING_AUTHORIZATION_ENDPOINT'
|
||||
| 'MISSING_TOKEN_ENDPOINT'
|
||||
| 'MISSING_USERINFO_ENDPOINT'
|
||||
| 'MISSING_REQUIRED_CLAIMS'
|
||||
| 'UNKNOWN_ERROR';
|
||||
|
||||
/**
|
||||
* Represents a "configured" OIDC setup for Headplane.
|
||||
* This may include mis-configured versions too and will surface error messages.
|
||||
*/
|
||||
export type OidcConnector =
|
||||
| {
|
||||
isValid: true;
|
||||
isExclusive: boolean;
|
||||
client: oidc.Configuration;
|
||||
apiKey: string;
|
||||
scope: string;
|
||||
extraParams?: Record<string, string>;
|
||||
}
|
||||
| {
|
||||
isValid: false;
|
||||
isExclusive: false;
|
||||
errors: OidcConnectorError[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates an OIDC connector based on the configuration and Headscale API.
|
||||
* This will attempt to validate the configuration and return any errors.
|
||||
*
|
||||
* @param config The OIDC configuration.
|
||||
* @param client The Headscale runtime API client.
|
||||
* @returns An OIDC connector with validation status.
|
||||
*/
|
||||
export async function createOidcConnector(
|
||||
config: OidcConfig,
|
||||
client: RuntimeApiClient,
|
||||
): Promise<OidcConnector> {
|
||||
// TODO: MEANINGFUL LOGS NOT JUST DEBUG SPAM LOL
|
||||
//
|
||||
const errors: OidcConnectorError[] = [];
|
||||
if (!config.headscale_api_key) {
|
||||
errors.push('INVALID_API_KEY');
|
||||
return {
|
||||
isValid: false,
|
||||
isExclusive: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await client.getApiKeys();
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
errors.push('INVALID_API_KEY');
|
||||
return {
|
||||
isValid: false,
|
||||
isExclusive: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
// MARK: Otherwise assume the API key is valid since the API request
|
||||
// failed for another reason that isn't 401 and we are optimistic
|
||||
}
|
||||
|
||||
const oidcClientOrErrors = await discoveryCoalesce(config);
|
||||
if (Array.isArray(oidcClientOrErrors)) {
|
||||
errors.push(...oidcClientOrErrors);
|
||||
return {
|
||||
isValid: false,
|
||||
isExclusive: false,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
isExclusive: config.disable_api_key_login,
|
||||
client: oidcClientOrErrors,
|
||||
apiKey: config.headscale_api_key,
|
||||
scope: config.scope,
|
||||
extraParams: config.extra_params,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs OIDC discovery and coalesces the results with the provided config.
|
||||
* We treat the manually supplied values as overrides to discovery.
|
||||
*
|
||||
* @param config The OIDC configuration.
|
||||
* @returns The coalesced OIDC configuration or an array of errors.
|
||||
*/
|
||||
async function discoveryCoalesce(
|
||||
config: OidcConfig,
|
||||
): Promise<oidc.Configuration | OidcConnectorError[]> {
|
||||
let metadata: oidc.ServerMetadata;
|
||||
try {
|
||||
const client = await oidc.discovery(
|
||||
new URL(config.issuer),
|
||||
config.client_id,
|
||||
);
|
||||
metadata = client.serverMetadata();
|
||||
if (metadata.claims_supported != null) {
|
||||
if (!metadata.claims_supported.includes('sub')) {
|
||||
log.error('config', 'OIDC provider does not support `sub` claim');
|
||||
return ['MISSING_REQUIRED_CLAIMS'];
|
||||
}
|
||||
|
||||
if (!metadata.claims_supported.includes('name')) {
|
||||
if (
|
||||
!(
|
||||
metadata.claims_supported.includes('given_name') &&
|
||||
metadata.claims_supported.includes('family_name')
|
||||
)
|
||||
) {
|
||||
log.warn(
|
||||
'config',
|
||||
'OIDC provider does not support `name`, `given_name`, or `family_name` claims',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!metadata.claims_supported.includes('preferred_username') &&
|
||||
!metadata.claims_supported.includes('email')
|
||||
) {
|
||||
log.warn(
|
||||
'config',
|
||||
'OIDC provider does not support `preferred_username` or `email` claims',
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
log.error(
|
||||
'config',
|
||||
'Failed to auto-configure OIDC endpoints via discovery',
|
||||
);
|
||||
log.warn(
|
||||
'config',
|
||||
'OIDC server may not support discovery, using manual config',
|
||||
);
|
||||
metadata = {
|
||||
issuer: config.issuer,
|
||||
};
|
||||
}
|
||||
|
||||
const errors: OidcConnectorError[] = [];
|
||||
const authorization_endpoint =
|
||||
config.authorization_endpoint ?? metadata.authorization_endpoint;
|
||||
|
||||
const token_endpoint = config.token_endpoint ?? metadata.token_endpoint;
|
||||
|
||||
const userinfo_endpoint =
|
||||
config.userinfo_endpoint ?? metadata.userinfo_endpoint;
|
||||
|
||||
if (!authorization_endpoint) {
|
||||
errors.push('MISSING_AUTHORIZATION_ENDPOINT');
|
||||
}
|
||||
|
||||
if (!token_endpoint) {
|
||||
errors.push('MISSING_TOKEN_ENDPOINT');
|
||||
}
|
||||
|
||||
if (!userinfo_endpoint) {
|
||||
errors.push('MISSING_USERINFO_ENDPOINT');
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const oidcClient = new oidc.Configuration(
|
||||
{
|
||||
issuer: config.issuer,
|
||||
authorization_endpoint,
|
||||
token_endpoint,
|
||||
userinfo_endpoint,
|
||||
},
|
||||
config.client_id,
|
||||
config.client_secret,
|
||||
);
|
||||
|
||||
return oidcClient;
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
import * as oidc from 'openid-client';
|
||||
import log from '~/utils/log';
|
||||
import { HeadplaneConfig } from '../config/schema';
|
||||
|
||||
export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
|
||||
|
||||
export async function configureOidcAuth(config: OidcConfig) {
|
||||
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
|
||||
let clientAuthMethod: oidc.ClientAuth;
|
||||
switch (config.token_endpoint_auth_method) {
|
||||
case 'client_secret_basic':
|
||||
clientAuthMethod = oidc.ClientSecretBasic(config.client_secret!);
|
||||
break;
|
||||
case 'client_secret_post':
|
||||
clientAuthMethod = oidc.ClientSecretPost(config.client_secret!);
|
||||
break;
|
||||
case 'client_secret_jwt':
|
||||
clientAuthMethod = oidc.ClientSecretJwt(config.client_secret!);
|
||||
break;
|
||||
default:
|
||||
throw new Error('Invalid client authentication method');
|
||||
}
|
||||
|
||||
let oidcClient: oidc.Configuration;
|
||||
try {
|
||||
const discovery = await oidc.discovery(
|
||||
new URL(config.issuer),
|
||||
config.client_id,
|
||||
config.client_secret!, // TODO: Fix this config schema
|
||||
clientAuthMethod,
|
||||
);
|
||||
|
||||
const meta = discovery.serverMetadata();
|
||||
if (!meta.authorization_endpoint) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery did not return `authorization_endpoint`',
|
||||
);
|
||||
log.error(
|
||||
'config',
|
||||
'OIDC server does not support authorization code flow',
|
||||
);
|
||||
log.error('config', 'You may need to set this manually in the config');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!meta.token_endpoint) {
|
||||
log.error('config', 'Issuer discovery did not return `token_endpoint`');
|
||||
log.error(
|
||||
'config',
|
||||
'OIDC server does not support authorization code flow',
|
||||
);
|
||||
log.error('config', 'You may need to set this manually in the config');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!meta.userinfo_endpoint) {
|
||||
log.error(
|
||||
'config',
|
||||
'Issuer discovery did not return `userinfo_endpoint`',
|
||||
);
|
||||
log.error('config', 'OIDC server does not support user info endpoint');
|
||||
log.error('config', 'You may need to set this manually in the config');
|
||||
return;
|
||||
}
|
||||
|
||||
if (meta.token_endpoint_auth_methods_supported) {
|
||||
if (
|
||||
!meta.token_endpoint_auth_methods_supported.includes(
|
||||
config.token_endpoint_auth_method,
|
||||
)
|
||||
) {
|
||||
log.error(
|
||||
'config',
|
||||
'OIDC server does not support client authentication method %s',
|
||||
config.token_endpoint_auth_method,
|
||||
);
|
||||
log.error(
|
||||
'config',
|
||||
'Supported methods: %s',
|
||||
meta.token_endpoint_auth_methods_supported.join(', '),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
log.debug('config', 'OIDC discovery successful');
|
||||
log.debug(
|
||||
'config',
|
||||
'Authorization endpoint: %s',
|
||||
meta.authorization_endpoint,
|
||||
);
|
||||
log.debug('config', 'Token endpoint: %s', meta.token_endpoint);
|
||||
log.debug('config', 'Userinfo endpoint: %s', meta.userinfo_endpoint);
|
||||
|
||||
// Manually construct the endpoints to coalesce with config if needed
|
||||
oidcClient = new oidc.Configuration(
|
||||
{
|
||||
issuer: config.issuer,
|
||||
authorization_endpoint:
|
||||
config.authorization_endpoint || meta.authorization_endpoint,
|
||||
token_endpoint: config.token_endpoint || meta.token_endpoint,
|
||||
userinfo_endpoint: config.userinfo_endpoint || meta.userinfo_endpoint,
|
||||
},
|
||||
config.client_id,
|
||||
config.client_secret!,
|
||||
clientAuthMethod,
|
||||
);
|
||||
} catch (err) {
|
||||
log.error('config', 'OIDC discovery failed: %s', err);
|
||||
log.debug('config', 'Error details: %o', err);
|
||||
log.error(
|
||||
'config',
|
||||
'This may be an error, or the server may not support discovery',
|
||||
);
|
||||
|
||||
if (
|
||||
!config.authorization_endpoint ||
|
||||
!config.token_endpoint ||
|
||||
!config.userinfo_endpoint
|
||||
) {
|
||||
log.error(
|
||||
'config',
|
||||
'Endpoints are not fully configured, cannot continue',
|
||||
);
|
||||
log.error(
|
||||
'config',
|
||||
'You must set authorization_endpoint, token_endpoint and userinfo_endpoint manually in the config or fix the discovery issue',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
oidcClient = new oidc.Configuration(
|
||||
{
|
||||
issuer: config.issuer,
|
||||
authorization_endpoint: config.authorization_endpoint,
|
||||
token_endpoint: config.token_endpoint,
|
||||
userinfo_endpoint: config.userinfo_endpoint,
|
||||
},
|
||||
config.client_id,
|
||||
config.client_secret!,
|
||||
clientAuthMethod,
|
||||
);
|
||||
|
||||
log.debug('config', 'Using manually configured endpoints');
|
||||
log.debug(
|
||||
'config',
|
||||
'Authorization endpoint: %s',
|
||||
config.authorization_endpoint,
|
||||
);
|
||||
log.debug('config', 'Token endpoint: %s', config.token_endpoint);
|
||||
log.debug('config', 'Userinfo endpoint: %s', config.userinfo_endpoint);
|
||||
}
|
||||
|
||||
log.info('config', 'Successfully configured OIDC authentication');
|
||||
return oidcClient;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user