Compare commits

..

67 Commits

Author SHA1 Message Date
Aarnav Tale 931a7f8d14 chore: upgrade dev to 0.28 2026-02-08 11:11:56 -05:00
Aarnav Tale 0080cddb55 chore: fix some test issues 2026-02-08 11:11:44 -05:00
Aarnav Tale 0b8ae8fa2d feat: fix pre-auth-key dialog to be usable again 2026-02-08 11:09:03 -05:00
Aarnav Tale 9f0bc4c46a Merge pull request #440 from tale/update_flake_lock_action
chore: update flake.lock
2026-02-08 11:07:04 -05:00
Aarnav Tale 8f445ce347 Merge pull request #436 from kronenpj/Adapt-owner-change-2922
Adapt to removal of Node Ownership Change from Headscale #2922
2026-02-08 11:06:52 -05:00
github-actions[bot] b68722eff0 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/26b0093' (2026-01-24)
  → 'github:nixos/nixpkgs/ae67888' (2026-02-06)
2026-02-08 08:35:26 +00:00
Paul Kronenwetter b38b733e9f Conditionally skip node reassignment test where version >= 0.28.0 2026-02-06 15:17:28 -05:00
Paul Kronenwetter 0d0b5f88a1 chore(machines): conditionally show owner change menu item based on version
Adds conditional rendering for the "Change owner" menu item in machine management, hiding it for versions 0.28.0-beta.1 and above. This prevents users from attempting to change node ownership on newer versions where this functionality is not supported. The change involves passing a new `supportsNodeOwnerChange` prop through the component hierarchy and updating the menu rendering logic accordingly.
2026-02-06 13:47:36 -05:00
Aarnav Tale fad0c99fc9 feat: official 0.28 support possibly 2026-02-06 00:11:26 -05:00
Aarnav Tale d2c16d2380 Merge pull request #433 from kronenpj/Correct-Tags-API-Change-#2993
Address tags incompatiblity introduced in 0.28.0-beta.2
2026-02-05 22:31:35 -05:00
Aarnav Tale 2b0f337cb5 Merge pull request #438 from SethMilliken/docs/fix-nginx-location-admin-proxy-pass 2026-01-26 10:47:14 -05:00
Aarnav Tale 5fbb4358ad Merge pull request #437 from tale/update_flake_lock_action 2026-01-26 10:46:27 -05:00
Seth Milliken 573dd7e52e docs: fix nginx proxy_pass for "location /admin/" stanza
As-is, the nginx sample configuration maps the location from the
original URL (`/admin/`) to the location in the `pass_proxy` argument
`http://localhost:3000/` (`/`) .

Headplane does not have a route for `/`, so it returns a `404 Not Found`
with this configuration. Headplane _does_ have a route for `/admin/`. By
omitting the location portion of the `pass_proxy` argument, the location
from the original URL is used (c.f.
https://docs.nginx.com/nginx/admin-guide/web-server/reverse-proxy/#passing-a-request-to-a-proxied-server),
serving up the correct content.
2026-01-25 18:37:31 -08:00
github-actions[bot] b5c94f9bc9 flake.lock: Update
Flake lock file updates:

• Updated input 'devshell':
    'github:numtide/devshell/17ed8d9' (2025-11-24)
  → 'github:numtide/devshell/255a2b1' (2026-01-19)
• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/be5afa0' (2026-01-16)
  → 'github:nixos/nixpkgs/26b0093' (2026-01-24)
2026-01-25 08:29:38 +00:00
Aarnav Tale 696e08dbcc feat: normalize node tags before leaving the api 2026-01-24 20:30:57 -05:00
Paul Kronenwetter 5777a5f509 Support previous API interface as well 2026-01-24 20:30:57 -05:00
Paul Kronenwetter c61a9119a7 Address tags incompatiblity introduced in 0.28.0-beta.2
From change (#2993)[https://github.com/juanfont/headscale/pull/2993]
2026-01-24 20:30:57 -05:00
Aarnav Tale a710995f97 chore: nix hashes suck 2026-01-24 18:41:32 -05:00
Aarnav Tale c8861ec978 chore: update react-router-hono-server due to vulnerability 2026-01-24 13:00:34 -05:00
Aarnav Tale 1a1d7dbe6a feat: fix api-key login on headscale 0.28+ 2026-01-24 12:58:07 -05:00
Aarnav Tale ec1d34e449 Merge pull request #431 from tale/update_flake_lock_action 2026-01-21 15:37:08 -05:00
github-actions[bot] 97499ed0c2 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/3146c6a' (2026-01-10)
  → 'github:nixos/nixpkgs/be5afa0' (2026-01-16)
2026-01-18 08:28:46 +00:00
Aarnav Tale 403f9c9b0b feat: add lazy retrying oidc connector 2026-01-16 00:33:10 -05:00
Aarnav Tale 2618cdd9f6 fix: strengthen the proc scanner for headscale 2026-01-16 00:20:26 -05:00
Aarnav Tale 15d145bf01 fix: bruh 2026-01-15 22:16:49 -05:00
Aarnav Tale 40361175e5 chore: update dependencies 2026-01-15 22:11:19 -05:00
Aarnav Tale 034ba25ce3 feat: switch to oxc linting/formatting 2026-01-15 22:09:58 -05:00
Aarnav Tale 0df4130217 fix: return container width back to 96rem 2026-01-15 21:55:36 -05:00
Aarnav Tale 62325f7c86 chore: fallback to client_secret_post for OIDC 2026-01-15 21:54:55 -05:00
Aarnav Tale 42ffe69486 feat: add docker health check 2026-01-13 22:54:11 -05:00
Aarnav Tale 0fb02d0d8b feat: implement better token auth endpoint method heuristic for oidc 2026-01-13 22:17:17 -05:00
Aarnav Tale d189d75d5d fix: resolve random css issues with mobile layouts 2026-01-11 15:55:14 -05:00
Aarnav Tale 8105b8f94d fix: enforce machine key length being 24 on registration 2026-01-11 15:20:00 -05:00
Aarnav Tale 5953740769 chore: update issue templates for new labels 2026-01-11 15:03:23 -05:00
Aarnav Tale 7a345d118c feat: check and warn for pkce errors if possible 2026-01-11 14:55:23 -05:00
Aarnav Tale 0965e16401 fix: apply gravatar images to the users page 2026-01-11 14:55:22 -05:00
Aarnav Tale e57821df14 Merge pull request #419 from tale/update_flake_lock_action 2026-01-11 14:41:10 -05:00
github-actions[bot] dfdf5d3ff9 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/3edc4a3' (2025-12-27)
  → 'github:nixos/nixpkgs/3146c6a' (2026-01-10)
2026-01-11 08:28:57 +00:00
Aarnav Tale 4046c6edb1 Merge pull request #409 from 0nano/0nano-doc-sso-patch 2025-12-31 15:11:44 -05:00
Aarnav Tale 7817addbc6 Merge pull request #408 from tale/update_flake_lock_action 2025-12-31 15:06:10 -05:00
github-actions[bot] e727c48406 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/f997fa0' (2025-12-11)
  → 'github:nixos/nixpkgs/3edc4a3' (2025-12-27)
2025-12-28 08:28:47 +00:00
_nano 85a0f932a7 Update OIDC callback URL in SSO documentation 2025-12-21 19:07:39 +01:00
Aarnav Tale 985d7d9dc6 chore: oops forgot changelog 2025-12-14 15:09:57 -05:00
Aarnav Tale 91254902fa Merge pull request #398 from tale/update_flake_lock_action 2025-12-14 13:27:48 -05:00
github-actions[bot] 55ffd5e841 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/a672be6' (2025-12-05)
  → 'github:nixos/nixpkgs/f997fa0' (2025-12-11)
2025-12-14 08:27:39 +00:00
Aarnav Tale 82cb74b20b fix: handle user setting owner to itself 2025-12-14 00:42:21 -05:00
Aarnav Tale e373c4a65e chore: remove some random console.logs 2025-12-13 16:35:19 -05:00
Aarnav Tale 7b4966be02 feat: track headscale acl response changes 2025-12-13 16:34:23 -05:00
Aarnav Tale 3d07b941b4 Merge pull request #389 from tale/update_flake_lock_action 2025-12-13 14:24:23 -05:00
Aarnav Tale 862180df91 Merge pull request #384 from Murgeye/main 2025-12-13 14:23:47 -05:00
Aarnav Tale 11a7c335f7 Merge pull request #394 from Lapin0t/main 2025-12-13 14:13:03 -05:00
lapinot 3d71049afe fix: accept the full discovered oidc configuration 2025-12-12 21:26:35 +01:00
Fabian Ising 8948d440fc Improve OIDC error logging 2025-12-08 10:55:19 +01:00
github-actions[bot] d5dffbaa1d flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/a09378c' (2025-11-29)
  → 'github:nixos/nixpkgs/a672be6' (2025-12-05)
2025-12-07 08:26:05 +00:00
Aarnav Tale d1e2773179 fix: use healthy/unhealthy for info endpoint 2025-12-06 15:03:57 -05:00
Aarnav Tale 295dd43059 feat: add info route 2025-12-06 15:02:18 -05:00
Aarnav Tale b3791385b9 Merge pull request #371 from tale/update_flake_lock_action 2025-12-06 14:44:14 -05:00
Aarnav Tale 70d535fe8d Merge pull request #377 from The-Greg-O/feat/machines-search-sort 2025-12-05 15:46:09 -05:00
Aarnav Tale 2ff5dd290f docs: enable search 2025-12-05 15:38:36 -05:00
Aarnav Tale 1463a8cc42 docs: go more indepth on sso setup 2025-12-05 15:38:16 -05:00
The-Greg-O 87485a81f3 refactor: move formatTimeDelta to shared utils
Moves the formatTimeDelta helper function to app/utils/time.ts
for reusability across the codebase, as requested in PR review.
2025-12-04 12:43:52 -08:00
Aarnav Tale 3a3e5ca65e feat: pkce 2025-12-04 11:37:22 -05:00
Aarnav Tale 3eff763436 docs: create initial sso page 2025-12-04 10:52:16 -05:00
Aarnav Tale 6ad0847653 fix: the login page would crash if OIDC is not enabled correctly 2025-12-04 10:39:24 -05:00
Aarnav Tale 199ef46ee1 fix: correctly passthrough and handle a new OIDC redirect_uri system 2025-12-04 10:34:48 -05:00
The-Greg-O b938642da7 feat: add search and sortable columns to machines list
- Add search input to filter machines by name or IP address
- Add sortable column headers for Name, IP, Version, and Last Seen
- Sort IP addresses numerically by octets (not alphabetically)
- Sort versions numerically by segments (1.10.0 > 1.9.0)
- Sort Last Seen with online machines treated as most recent
- Add time delta display for offline machines (e.g., "2 days ago")
- Add machine count display showing filtered/total counts
- Add clear button (X) to search input

Closes #351
2025-12-03 02:07:29 -08:00
github-actions[bot] e868f177aa flake.lock: Update
Flake lock file updates:

• Updated input 'devshell':
    'github:numtide/devshell/07bacc9' (2025-11-07)
  → 'github:numtide/devshell/17ed8d9' (2025-11-24)
• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/1d4c883' (2025-11-15)
  → 'github:nixos/nixpkgs/a09378c' (2025-11-29)
2025-11-30 08:25:12 +00:00
82 changed files with 7740 additions and 6310 deletions
+26 -26
View File
@@ -1,31 +1,31 @@
name: Bug Report
description: Report an issue with Headplane
assignees: [tale]
labels: [bug, triage]
labels: ["Bug", "Needs Triage"]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the issue and steps to reproduce it.
If applicable, include any error messages or screenshots.
- type: textarea
attributes:
label: Description
description: |
A detailed description of the issue and steps to reproduce it.
If applicable, include any error messages or screenshots.
If this is not an issue with Headplane, but an issue with your
environment, please consider opening a discussion instead.
placeholder: e.g. "When I try to upload a file, I get an error message."
validations:
required: true
- type: input
attributes:
label: Headplane Version
description: What version of Headplane are you using?
placeholder: e.g. "v0.5.5"
validations:
required: true
- type: input
attributes:
label: Headscale Version
description: What version of Headscale are you using?
placeholder: e.g. "v0.25.1"
validations:
required: true
If this is not an issue with Headplane, but an issue with your
environment, please consider opening a discussion instead.
placeholder: e.g. "When I try to upload a file, I get an error message."
validations:
required: true
- type: input
attributes:
label: Headplane Version
description: What version of Headplane are you using?
placeholder: e.g. "v0.6.2"
validations:
required: true
- type: input
attributes:
label: Headscale Version
description: What version of Headscale are you using?
placeholder: e.g. "v0.27.1"
validations:
required: true
+11 -11
View File
@@ -1,15 +1,15 @@
name: Feature Request
description: Request a new feature or enhancement for Headplane
assignees: [tale]
labels: [enhancement, triage]
labels: ["Feature", "Needs Triage"]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the feature you would like to see added.
Please include any relevant context, such as why this feature is
important and how it would benefit other users beyond yourself.
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
validations:
required: true
- type: textarea
attributes:
label: Description
description: |
A detailed description of the feature you would like to see added.
Please include any relevant context, such as why this feature is
important and how it would benefit other users beyond yourself.
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
validations:
required: true
+10
View File
@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"endOfLine": "lf",
"experimentalSortImports": {},
"experimentalTailwindcss": {},
"ignorePatterns": [],
"printWidth": 100,
"tabWidth": 2,
"useTabs": false
}
+144
View File
@@ -0,0 +1,144 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["unicorn", "typescript", "oxc"],
"categories": {},
"rules": {
"constructor-super": "warn",
"for-direction": "warn",
"no-async-promise-executor": "warn",
"no-caller": "warn",
"no-class-assign": "warn",
"no-compare-neg-zero": "warn",
"no-cond-assign": "warn",
"no-const-assign": "warn",
"no-constant-binary-expression": "warn",
"no-constant-condition": "warn",
"no-control-regex": "warn",
"no-debugger": "warn",
"no-delete-var": "warn",
"no-dupe-class-members": "warn",
"no-dupe-else-if": "warn",
"no-dupe-keys": "warn",
"no-duplicate-case": "warn",
"no-empty-character-class": "warn",
"no-empty-pattern": "warn",
"no-empty-static-block": "warn",
"no-eval": "warn",
"no-ex-assign": "warn",
"no-extra-boolean-cast": "warn",
"no-func-assign": "warn",
"no-global-assign": "warn",
"no-import-assign": "warn",
"no-invalid-regexp": "warn",
"no-irregular-whitespace": "warn",
"no-loss-of-precision": "warn",
"no-new-native-nonconstructor": "warn",
"no-nonoctal-decimal-escape": "warn",
"no-obj-calls": "warn",
"no-self-assign": "warn",
"no-setter-return": "warn",
"no-shadow-restricted-names": "warn",
"no-sparse-arrays": "warn",
"no-this-before-super": "warn",
"no-unassigned-vars": "warn",
"no-unsafe-finally": "warn",
"no-unsafe-negation": "warn",
"no-unsafe-optional-chaining": "warn",
"no-unused-expressions": "warn",
"no-unused-labels": "warn",
"no-unused-private-class-members": "warn",
"no-unused-vars": "warn",
"no-useless-backreference": "warn",
"no-useless-catch": "warn",
"no-useless-escape": "warn",
"no-useless-rename": "warn",
"no-with": "warn",
"require-yield": "warn",
"use-isnan": "warn",
"valid-typeof": "warn",
"oxc/bad-array-method-on-arguments": "warn",
"oxc/bad-char-at-comparison": "warn",
"oxc/bad-comparison-sequence": "warn",
"oxc/bad-min-max-func": "warn",
"oxc/bad-object-literal-comparison": "warn",
"oxc/bad-replace-all-arg": "warn",
"oxc/const-comparisons": "warn",
"oxc/double-comparisons": "warn",
"oxc/erasing-op": "warn",
"oxc/missing-throw": "warn",
"oxc/number-arg-out-of-range": "warn",
"oxc/only-used-in-recursion": "warn",
"oxc/uninvoked-array-callback": "warn",
"typescript/await-thenable": "warn",
"typescript/no-array-delete": "warn",
"typescript/no-base-to-string": "warn",
"typescript/no-duplicate-enum-values": "warn",
"typescript/no-duplicate-type-constituents": "warn",
"typescript/no-extra-non-null-assertion": "warn",
"typescript/no-floating-promises": "warn",
"typescript/no-for-in-array": "warn",
"typescript/no-implied-eval": "warn",
"typescript/no-meaningless-void-operator": "warn",
"typescript/no-misused-new": "warn",
"typescript/no-misused-spread": "warn",
"typescript/no-non-null-asserted-optional-chain": "warn",
"typescript/no-redundant-type-constituents": "warn",
"typescript/no-this-alias": "warn",
"typescript/no-unnecessary-parameter-property-assignment": "warn",
"typescript/no-unsafe-declaration-merging": "warn",
"typescript/no-unsafe-unary-minus": "warn",
"typescript/no-useless-empty-export": "warn",
"typescript/no-wrapper-object-types": "warn",
"typescript/prefer-as-const": "warn",
"typescript/require-array-sort-compare": "warn",
"typescript/restrict-template-expressions": "warn",
"typescript/triple-slash-reference": "warn",
"typescript/unbound-method": "warn",
"unicorn/no-await-in-promise-methods": "warn",
"unicorn/no-empty-file": "warn",
"unicorn/no-invalid-fetch-options": "warn",
"unicorn/no-invalid-remove-event-listener": "warn",
"unicorn/no-new-array": "warn",
"unicorn/no-single-promise-in-promise-methods": "warn",
"unicorn/no-thenable": "warn",
"unicorn/no-unnecessary-await": "warn",
"unicorn/no-useless-fallback-in-spread": "warn",
"unicorn/no-useless-length-check": "warn",
"unicorn/no-useless-spread": "warn",
"unicorn/prefer-set-size": "warn",
"unicorn/prefer-string-starts-ends-with": "warn"
},
"settings": {
"jsx-a11y": {
"polymorphicPropName": null,
"components": {},
"attributes": {}
},
"next": {
"rootDir": []
},
"react": {
"formComponents": [],
"linkComponents": [],
"version": null
},
"jsdoc": {
"ignorePrivate": false,
"ignoreInternal": false,
"ignoreReplacesDocs": true,
"overrideReplacesDocs": true,
"augmentsExtendsReplacesDocs": false,
"implementsReplacesDocs": false,
"exemptDestructuredRootsFromChecks": false,
"tagNamePreference": {}
},
"vitest": {
"typecheck": false
}
},
"env": {
"builtin": true
},
"globals": {},
"ignorePatterns": []
}
+77 -24
View File
@@ -1,4 +1,6 @@
# 0.6.2 (Next)
- Added search and sortable columns to the machines list page (closes [#351](https://github.com/tale/headplane/issues/351)).
- Added support for Headscale 0.27.0 and 0.27.1
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
- Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)).
@@ -6,26 +8,42 @@
- Attempt to warn against misconfigured cookie settings on the login page.
- Made `server.cookie_max_age` and `server.cookie_domain` configurable (closes [#348](https://github.com/tale/headplane/issues/348)).
- Re-worked the configuration loading system with several enhancements:
- It is now possible to skip a configuration file and only use environment variables (closes [#150](https://github.com/tale/headplane/issues/150)).
- Secret path loading has been reworked from the ground up to be more reliable (closes [#334](https://github.com/tale/headplane/issues/334)).
- Added better testing and validation for configuration loading
- It is now possible to skip a configuration file and only use environment variables (closes [#150](https://github.com/tale/headplane/issues/150)).
- Secret path loading has been reworked from the ground up to be more reliable (closes [#334](https://github.com/tale/headplane/issues/334)).
- Added better testing and validation for configuration loading
- Re-worked the OIDC integration to adhere to the correct standards and surface more errors to the user.
- Deprecated `oidc.redirect_uri` and automated callback URL detection in favor of setting `server.base_url` correctly.
- Explicitly added `oidc.use_pkce` to correctly determine PKCE configuration.
- `oidc.token_endpoint_auth_method` is now optional and will attempt to be auto-detected, defaulting to `client_secret_basic` if unavailable (closes [#410](https://github.com/tale/headplane/issues/410)).
- Removed several unnecessarily verbose or spammy log messages.
- Updated the minimum Docker API used to support the latest Docker versions (via [#370](https://github.com/tale/headplane/pull/370)).
- Enhanced the node tag dialog to show a dropdown of assignable tags (via [#362](https://github.com/tale/headplane/pull/362)).
- Fixed an issue where the website favicon would not load correctly (closes [#323](https://github.com/tale/headplane/issues/323)).
- Correctly handle invalid ACL policy inserts on Headscale 0.27+ (closes [#383](https://github.com/tale/headplane/issues/383)).
- Prevent a machine from changing its owner to itself (closes [#373](https://github.com/tale/headplane/issues/373)).
- Added an `/admin/api/info` route that can expose sensitive information if `server.info_secret` is set in the configuration (closes [#324](https://github.com/tale/headplane/issues/324)).
- Correctly apply Gravatar profile pictures on the user page if applicable (closes [#405](https://github.com/tale/headplane/issues/405)).
- Machine key registration no longer works if the key isn't 24 characters long (closes [#415](https://github.com/tale/headplane/issues/415)).
- Fixed some mobile CSS issues across the application (closes [#401](https://github.com/tale/headplane/issues/401)).
- Added a Docker healthcheck to the container (closes [#411](https://github.com/tale/headplane/issues/411)).
- Strengthened the validation for the `/proc` integration to correctly discover the Headscale PID.
- Added lazy retry logic for OIDC providers if they initially fail to respond (closes [#423](https://github.com/tale/headplane/issues/423)).
- Fixed API key login on Headcale 0.28.0-beta.1+ (closes [#429](https://github.com/tale/headplane/issues/429)).
- Fixed an issue that prevented the pre-auth-key UI from being usable on Headscale 0.28 and later.
---
# 0.6.1 (October 12, 2025)
- **Headplane now supports connecting to machines via SSH in the web browser.**
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
- This is an experimental feature and requires the `integration.agent` section to be set up in the config file.
- This is built on top of a Go binary that runs in WebAssembly, using Xterm.js for the terminal interface.
- Begin using a new SQLite database file in `/var/lib/headplane/hp_persist.db`.
- The database is created automatically if it does not exist.
- It currently stores SSH connection details and HostInfo for the agent.
- User information is automatically migrated from the previous database.
- The database is created automatically if it does not exist.
- It currently stores SSH connection details and HostInfo for the agent.
- User information is automatically migrated from the previous database.
- The docker container now runs in a distroless image (closes [#255](https://github.com/tale/headplane/issues/255)).
- A debug version of the container that runs as root and has a shell is available as `ghcr.io/tale/headplane:<version>-shell`.
- A debug version of the container that runs as root and has a shell is available as `ghcr.io/tale/headplane:<version>-shell`.
- Removing a Split DNS record will no longer make the split domain unresolvable by clients (closes [#231](https://github.com/tale/headplane/issues/231)).
- Reintroduce the toggle for overriding local DNS settings in the Headscale config (closes [#236](https://github.com/tale/headplane/issues/236)).
- Prefer cross-compiling in the Dockerfile to speed up builds while still supporting multiple architectures.
@@ -33,32 +51,33 @@
- Implement more accurate guessing on the PID with the `/proc` integration (via [#219](https://github.com/tale/headplane/pull/219)).
- Usernames will now correctly fall back to emails if not provided (via [#257](https://github.com/tale/headplane/pull/257)).
- Configuration loading via paths is now supported for sensitive values (via [#283](https://github.com/tale/headplane/pulls/283))
- Options like `server.cookie_secret_path` can override `server.cookie_secret`
- Environment variables are interpolatable into these paths
- See the full reference in the [docs](https://github.com/tale/headplane/blob/main/docs/Configuration.md#sensitive-values)
- Options like `server.cookie_secret_path` can override `server.cookie_secret`
- Environment variables are interpolatable into these paths
- See the full reference in the [docs](https://github.com/tale/headplane/blob/main/docs/Configuration.md#sensitive-values)
- The nix overlay build is fixed for the SSH module (via [#282](https://github.com/tale/headplane/pull/282))
- Switch our build processes to use TypeScript Go and Rolldown Vite for better build and type-check performance.
- Cookies are now encrypted JWTs, preserving API key secrets (*GHSA-wrqq-v7qw-r5w7*)
- Cookies are now encrypted JWTs, preserving API key secrets (_GHSA-wrqq-v7qw-r5w7_)
- OIDC profile pictures are now available from Gravatar by setting `oidc.profile_picture_source` to `gravatar` (closes [#232](https://github.com/tale/headplane/issues/232)).
- OIDC now allows passing many custom parameters:
- `oidc.authorization_endpoint`, `oidc.token_endpoint`, and `oidc.userinfo_endpoint` can be overridden to support non-standard providers or scenarios without discovery (closes [#117](https://github.com/tale/headplane/issues/117)).
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
- `oidc.authorization_endpoint`, `oidc.token_endpoint`, and `oidc.userinfo_endpoint` can be overridden to support non-standard providers or scenarios without discovery (closes [#117](https://github.com/tale/headplane/issues/117)).
- `oidc.scope` can be set to specify custom scopes (defaults to `openid email profile`).
- `oidc.extra_params` can be set to pass arbitrary query parameters to the authorization endpoint (closes [#197](https://github.com/tale/headplane/issues/197)).
---
# 0.6.0 (May 25, 2025)
- Headplane 0.6.0 now requires **Headscale 0.26.0** or newer.
- Breaking API changes with routes and pre auth keys are now supported (closes [#204](https://github.com/tale/headplane/issues/204)).
- Older versions of Headscale will not work with Headplane.
- Breaking API changes with routes and pre auth keys are now supported (closes [#204](https://github.com/tale/headplane/issues/204)).
- Older versions of Headscale will not work with Headplane.
- OIDC authorization restrictions can now be controlled from the settings UI. (closes [#102](https://github.com/tale/headplane/issues/102))
- The required permission role for this is **IT Admin** or **Admin/Owner** and require the Headscale configuration.
- Changes made will modify the `oidc.allowed_{domains,groups,users}` fields in the Headscale config file.
- The required permission role for this is **IT Admin** or **Admin/Owner** and require the Headscale configuration.
- Changes made will modify the `oidc.allowed_{domains,groups,users}` fields in the Headscale config file.
- The Pre-Auth keys page has been fully reworked (closes [#179](https://github.com/tale/headplane/issues/179), [#143](https://github.com/tale/headplane/issues/143)).
- The Headplane agent is now available as an integration (closes [#65](https://github.com/tale/headplane/issues/65)).
- The agent runs as an embedded process alongside the Headplane server and reports host information and system metrics.
- Refer to the `integrations.agent` section of the config file for more information and how to enable it.
- The agent runs as an embedded process alongside the Headplane server and reports host information and system metrics.
- Refer to the `integrations.agent` section of the config file for more information and how to enable it.
- Requests to `/admin` will now be redirected to `/admin/` to prevent issues with the React Router (works with custom prefixes, closes [#173](https://github.com/tale/headplane/issues/173)).
- The Login page has been simplified and separately reports errors versus incorrect API keys (closes [#186](https://github.com/tale/headplane/issues/186)).
- The machine actions backend has been reworked to better handle errors and provide more information to the user (closes [#185](https://github.com/tale/headplane/issues/185)).
@@ -75,11 +94,13 @@
---
# 0.5.10 (April 4, 2025)
- Fix an issue where other preferences to skip onboarding affected every user.
---
# 0.5.9 (April 3, 2025)
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users.
- OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
- Suppress hydration errors for any fields with a date in it.
@@ -87,6 +108,7 @@
---
# 0.5.8 (April 3, 2025)
- You can now skip the onboarding page if desired.
- Added the UI to change user roles in the dashboard.
- Fixed an issue where integrations would throw instead of loading properly.
@@ -97,6 +119,7 @@
---
# 0.5.7 (April 2, 2025)
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
---
@@ -104,9 +127,10 @@
# 0.5.6 (April 2, 2025)
### IMPORTANT
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously *ANY* accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
Implemented *proper* authentication methods for OIDC.
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously _ANY_ accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
Implemented _proper_ authentication methods for OIDC.
This is a large update and copies the permission system from Tailscale.
Permissions are not automatically derived from OIDC, but they can be configured via the UI.
Additionally, certain roles give certain capabilities, limiting access to parts of the dashboard.
@@ -114,6 +138,7 @@ By default, new users will have a `member` role which forbids access to the UI.
If there are no users, the first user will be given an `owner` role which cannot be removed.
**Changes**:
- Switched the internal server to use `hono` for better performance.
- Fixed an issue that caused dialogs to randomly refocus every 3 seconds.
- Headplane will not send API requests when the tab is not focused.
@@ -124,11 +149,13 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.5.5 (March 18, 2025)
- Hotfix an issue that caused Headplane to crash if no agents are available
---
# 0.5.4 (March 18, 2025)
- Fixed a typo in the Kubernetes documentation
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
@@ -145,6 +172,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.5.3 (March 1, 2025)
- Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111))
- Added an ARIA indicator for when an input is required and fixed the confirm buttons (fixed [#116](https://github.com/tale/headplane/issues/116))
- Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112))
@@ -152,11 +180,13 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.5.2 (February 28, 2025)
- Hotfixed an issue where the server bundle got reloaded on each request
---
# 0.5.1 (February 28, 2025)
- Fixed an issue that caused the entire server to crash on start
- Fixed the published semver tags from Docker
- Fixed the Kubernetes integration not reading the config
@@ -164,6 +194,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.5 (February 27, 2025)
> This release is a major overhaul and contains a significant breaking change.
> We now use a config file for all settings instead of environment variables.
> Please see [config.example.yaml](/config.example.yaml) for the new format.
@@ -181,11 +212,13 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.4.1 (January 18, 2025)
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
---
# 0.4.0 (January 18, 2025)
- Switched from Remix.run to React-Router
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
@@ -201,11 +234,13 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.3.9 (December 6, 2024)
- Fixed a race condition bug in the OIDC validation code
---
# 0.3.8 (December 6, 2024)
- Added a little HTML footer to show the login page and link to a donation page.
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
@@ -213,6 +248,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.3.7 (November 30, 2024)
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
@@ -221,18 +257,21 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.3.6 (November 20, 2024)
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
---
# 0.3.5 (November 8, 2024)
- Quickfix a bug where environment variables are ignored on the server.
- Remove a nagging error about missing cookie since that happens when signed out.
---
# 0.3.4 (November 7, 2024)
- Clicking on the machine name in the users page now takes you to the machine overview page.
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
@@ -245,6 +284,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.3.3 (October 28, 2024)
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
- Improved ACL editor performance by switching back to CodeMirror 6
@@ -254,6 +294,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.3.2 (October 11, 2024)
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
@@ -263,6 +304,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.3.1 (October 3, 2024)
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
- Added the option for debug logs with the `DEBUG=true` environment variable.
@@ -270,12 +312,14 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.3.0 (September 25, 2024)
- Bumped the minimum supported version of Headscale to 0.23.
- Updated the UI to respect `dns.use_username_in_magic_dns`.
---
# 0.2.4 (August 24, 2024)
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
- Removed the `ACL_FILE` environment variable since it's no longer needed.
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
@@ -284,6 +328,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.2.3 (August 23, 2024)
- Change the minimum required version of Headscale to 0.23-beta2
- Support the new API policy mode for Headscale 0.23-beta1
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
@@ -292,6 +337,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.2.2 (August 2, 2024)
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
- Added a new logger utility that shows categories, levels, and timestamps.
- Reimplemented the integration system to be more resilient and log more information.
@@ -300,6 +346,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.2.1 (July 7, 2024)
- Added the ability to manage custom DNS records on your Tailnet.
- ACL tags for machines are now able to be changed via the machine menu.
- Fixed a bug where the ACL editor did not show the diffs correctly.
@@ -308,12 +355,14 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.2.0 (June 23, 2024)
- Fix the dropdown options for machines not working on the machines page.
- Add an option to change the machine owner in the dropdown (aside from the users page).
---
# 0.1.9 (June 2, 2024)
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
- Utilize magic DNS hostnames for the machine overview page.
- Fixed the expiry issue once and for all.
@@ -322,6 +371,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.1.8 (June 2, 2024)
- Built basic functionality for the machine overview page (by machine ID).
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
- Prevent users from being deleted if they still have ownership of machines.
@@ -330,6 +380,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.1.7 (May 30, 2024)
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
- Made the strict configuration loader more lenient to allow for more flexibility.
@@ -340,6 +391,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.1.6 (May 22, 2024)
- Added experimental support for advanced integration without Docker.
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
@@ -347,6 +399,7 @@ If there are no users, the first user will be given an `owner` role which cannot
---
# 0.1.5 (May 20, 2024)
- Robust configuration handling with fallbacks based on the headscale source.
- Support for `client_secret_path` on configuration file based OIDC.
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
+12 -2
View File
@@ -11,14 +11,16 @@ ARG TARGETOS
ARG TARGETARCH
ARG IMAGE_TAG
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 IMAGE_TAG=$IMAGE_TAG \
./build.sh --wasm --agent --fake-shell \
./build.sh --wasm --agent --fake-shell --healthcheck \
--wasm-output /bin/hp_ssh.wasm \
--agent-output /bin/hp_agent \
--fake-shell-output /bin/fake-sh
--fake-shell-output /bin/fake-sh \
--healthcheck-output /bin/hp_healthcheck
RUN chmod +x /bin/hp_ssh.wasm
RUN chmod +x /bin/hp_agent
RUN chmod +x /bin/fake-sh
RUN chmod +x /bin/hp_healthcheck
# Folder needs to exist for later stages
RUN mkdir -p /var/lib/headplane/agent
@@ -49,6 +51,10 @@ COPY --from=go-base /var/lib/headplane /var/lib/headplane
COPY --from=go-base /bin/fake-sh /bin/sh
COPY --from=go-base /bin/fake-sh /bin/bash
COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/bin/hp_healthcheck"]
WORKDIR /app
CMD [ "/app/build/server/index.js" ]
@@ -61,6 +67,10 @@ COPY --from=js-base /run/node_modules /app/node_modules
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
COPY --from=go-base /var/lib/headplane /var/lib/headplane
COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/bin/hp_healthcheck"]
WORKDIR /app
CMD [ "node", "/app/build/server/index.js" ]
+6 -6
View File
@@ -107,6 +107,11 @@ function Panel(props: DialogPanelProps) {
return (
<Form
{...dialogProps}
className={cn(
'outline-hidden rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
method={method ?? 'POST'}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
@@ -114,12 +119,7 @@ function Panel(props: DialogPanelProps) {
close?.();
}}
method={method ?? 'POST'}
ref={ref}
className={cn(
'outline-hidden rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
>
<Card className="w-full max-w-lg" variant="flat">
{children}
@@ -130,9 +130,9 @@ function Panel(props: DialogPanelProps) {
<>
<Button onPress={close}>Cancel</Button>
<Button
isDisabled={isDisabled}
type="submit"
variant={variant === 'destructive' ? 'danger' : 'heavy'}
isDisabled={isDisabled}
>
Confirm
</Button>
+17 -8
View File
@@ -8,11 +8,18 @@ export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
labelHidden?: boolean;
isRequired?: boolean;
className?: string;
isInvalid?: boolean;
errorMessage?: string;
}
// TODO: Custom isInvalid logic for custom error messages
export default function Input(props: InputProps) {
const { label, labelHidden, className } = props;
const {
label,
labelHidden,
className,
isInvalid: customIsInvalid,
errorMessage,
} = props;
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
@@ -21,7 +28,7 @@ export default function Input(props: InputProps) {
inputProps,
descriptionProps,
errorMessageProps,
isInvalid,
isInvalid: ariaIsInvalid,
validationErrors,
} = useTextField(
{
@@ -32,16 +39,18 @@ export default function Input(props: InputProps) {
ref,
);
const isInvalid = customIsInvalid ?? ariaIsInvalid;
return (
<div className="flex flex-col w-full" aria-label={label}>
<div className="flex flex-col w-full">
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
labelHidden && 'sr-only',
)}
htmlFor={id}
>
{label}
{props.isRequired && (
@@ -50,8 +59,6 @@ export default function Input(props: InputProps) {
</label>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
className={cn(
'rounded-xl px-3 py-2',
'focus:outline-hidden focus:ring-3',
@@ -59,6 +66,8 @@ export default function Input(props: InputProps) {
'border border-headplane-100 dark:border-headplane-800',
className,
)}
ref={ref}
required={props.isRequired}
/>
{props.description && (
<div
@@ -76,7 +85,7 @@ export default function Input(props: InputProps) {
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
{errorMessage ?? validationErrors.join(' ')}
</div>
) : null}
</div>
+5 -5
View File
@@ -23,15 +23,15 @@ function Tabs({ label, className, ...props }: TabsProps) {
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className={cn(
'flex items-center rounded-t-xl w-fit',
'flex items-center rounded-t-xl w-fit max-w-full overflow-x-auto',
'border-headplane-100 dark:border-headplane-800',
'border-t border-x',
)}
ref={ref}
>
{[...state.collection].map((item) => (
<Tab key={item.key} item={item} state={state} />
<Tab item={item} key={item.key} state={state} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
@@ -52,7 +52,6 @@ function Tab({ item, state }: TabsTabProps) {
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
@@ -60,6 +59,7 @@ function Tab({ item, state }: TabsTabProps) {
'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
)}
ref={ref}
>
{rendered}
</div>
@@ -76,11 +76,11 @@ function TabsPanel({ state, ...props }: TabsPanelProps) {
return (
<div
{...tabPanelProps}
ref={ref}
className={cn(
'w-full overflow-clip rounded-b-xl rounded-r-xl',
'border border-headplane-100 dark:border-headplane-800',
)}
ref={ref}
>
{state.selectedItem?.props.children}
</div>
+20 -11
View File
@@ -18,15 +18,26 @@ export function getErrorMessage(error: Error | unknown): {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
title: 'Cannot connect to Headscale API',
title: 'Headscale API Error',
jsxMessage: (
<Card.Text>
There was an error communicating with the Headscale API.
<br />
The server responded with a status code of{' '}
<strong>{statusCode}</strong>, indicating a server-side issue.
Please check the Headscale server status and try again later.
</Card.Text>
<>
<Card.Text>
There was an error communicating with the Headscale API.
<br />
The server responded with a status code of{' '}
<strong>{statusCode}</strong>, indicating a server-side issue.
Please check the Headscale server status and try again later.
</Card.Text>
{(error.data.data != null || error.data.rawData != null) && (
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{error.data.data != null ? (
<code>{JSON.stringify(error.data.data, null, 2)}</code>
) : (
<code>{error.data.rawData}</code>
)}
</pre>
)}
</>
),
};
}
@@ -124,7 +135,7 @@ export function getErrorMessage(error: Error | unknown): {
<br />
Status Code: <strong>{error.status}</strong>
<br />
Status Text: <strong>{error.statusText}</strong>
Status Text: <strong>{error.data}</strong>
</>
),
};
@@ -159,12 +170,10 @@ export function getErrorMessage(error: Error | unknown): {
// Traverse the error chain to find the root cause
let rootError = error;
console.log('error', error.cause != null);
if (error.cause != null) {
rootError = error.cause as Error;
while (rootError.cause != null) {
rootError = rootError.cause as Error;
console.log('setting rootError', rootError.message);
}
}
+54 -56
View File
@@ -1,67 +1,65 @@
import { PassThrough } from 'node:stream';
import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import type { AppLoadContext, EntryContext } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { isbot } from "isbot";
import { PassThrough } from "node:stream";
import { renderToPipeableStream } from "react-dom/server";
import { ServerRouter } from "react-router";
export const streamTimeout = 5_000;
export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
loadContext: AppLoadContext,
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
routerContext: EntryContext,
_loadContext: AppLoadContext,
) {
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get('user-agent');
return new Promise((resolve, reject) => {
let shellRendered = false;
const userAgent = request.headers.get("user-agent");
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode
? 'onAllReady'
: 'onShellReady';
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
const readyOption: keyof RenderToPipeableStreamOptions =
(userAgent && isbot(userAgent)) || routerContext.isSpaMode ? "onAllReady" : "onShellReady";
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
const { pipe, abort } = renderToPipeableStream(
<ServerRouter context={routerContext} url={request.url} />,
{
[readyOption]() {
shellRendered = true;
const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
responseHeaders.set('Content-Type', 'text/html');
responseHeaders.set("Content-Type", "text/html");
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
resolve(
new Response(stream, {
headers: responseHeaders,
status: responseStatusCode,
}),
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
// biome-ignore lint/style/noParameterAssign: Lazy
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
},
);
pipe(body);
},
onShellError(error: unknown) {
reject(error);
},
onError(error: unknown) {
responseStatusCode = 500;
// Log streaming rendering errors from inside the shell. Don't log
// errors encountered during initial shell rendering since they'll
// reject and get logged in handleDocumentRequest.
if (shellRendered) {
console.error(error);
}
},
},
);
// Abort the rendering stream after the `streamTimeout` so it has tine to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
// Abort the rendering stream after the `streamTimeout` so it has tine to
// flush down the rejected boundaries
setTimeout(abort, streamTimeout + 1000);
});
}
+4 -3
View File
@@ -1,5 +1,6 @@
{
"0.26.1": ["0.26.0", "0.26.1"],
"0.27.0": ["0.27.0"],
"0.27.1": ["0.27.1"]
"0.26.1": ["0.26.0", "0.26.1"],
"0.27.0": ["0.27.0"],
"0.27.1": ["0.27.1"],
"0.28.0": ["0.28.0"]
}
+133 -106
View File
@@ -1,108 +1,135 @@
{
"0.26.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.26.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
}
"0.26.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.26.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.28.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "b10ca7d2750405b2",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "8428b44e3a821e9e",
"DELETE /api/v1/preauthkey": "f05ea1bc8ad89a09",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<Links />
<link href="favicon.ico" rel="icon" />
</head>
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
<body className="overscroll-none overflow-x-hidden dark:bg-headplane-900 dark:text-headplane-50">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
+3
View File
@@ -5,6 +5,9 @@ export default [
index('routes/util/redirect.ts'),
route('/healthz', 'routes/util/healthz.ts'),
// API Routes
...prefix('/api', [route('/info', 'routes/util/info.ts')]),
// Authentication Routes
route('/login', 'routes/auth/login/page.tsx'),
route('/logout', 'routes/auth/logout.ts'),
+86 -55
View File
@@ -1,5 +1,5 @@
import { data } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
@@ -37,68 +37,99 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
updatedAt,
});
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('update is disabled')) {
// This means the policy is not writable
if (isDataWithApiError(error)) {
const rawData = error.data.rawData;
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
if (rawData.includes('update is disabled')) {
throw data('Policy is not writable', { status: 403 });
}
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
if (message.includes('parsing hujson')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err: hujson:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
const message =
error.data.data != null &&
'message' in error.data.data &&
typeof error.data.data.message === 'string'
? error.data.data.message
: undefined;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
if (message == null) {
throw error;
}
if (message.includes('unmarshalling policy')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
// we need to reference other error messages based on API version.
if (context.hsApi.clientHelpers.isAtleast('0.27.0')) {
if (message.includes('parsing HuJSON:')) {
const cutIndex = message.indexOf('parsing HuJSON:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 16).trim()}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('empty policy')) {
return data(
{
success: false,
error: 'Policy error: Supplied policy was empty',
policy: undefined,
updatedAt: undefined,
},
400,
);
if (message.includes('parsing policy from bytes:')) {
const cutIndex = message.indexOf('parsing policy from bytes:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 26).trim()}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
} else {
// Pre-0.27.0 error messages
if (message.includes('parsing hujson')) {
const cutIndex = message.indexOf('err: hujson:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('unmarshalling policy')) {
const cutIndex = message.indexOf('err:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
}
}
+6 -17
View File
@@ -1,5 +1,5 @@
import { data } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
@@ -30,30 +30,19 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
const api = context.hsApi.getRuntimeClient(session.api_key);
try {
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.
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('acl policy not found')) {
// This means the policy has never been initiated, and we can
// write to it to get it started or ignore it.
flags.policy = ''; // Start with an empty policy
if (isDataWithApiError(error)) {
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
if (error.data.rawData.includes('acl policy not found')) {
flags.policy = '';
flags.writable = true;
return flags;
}
return flags;
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}
+56 -2
View File
@@ -1,11 +1,19 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import {
AlertCircle,
Construction,
Eye,
FlaskConical,
Pencil,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { useFetcher, useRevalidator } from 'react-router';
import { isRouteErrorResponse, useFetcher, useRevalidator } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import { isApiError } from '~/server/headscale/api/error-client';
import toast from '~/utils/toast';
import type { Route } from './+types/overview';
import { aclAction } from './acl-action';
@@ -164,3 +172,49 @@ export default function Page({
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (
isRouteErrorResponse(error) &&
isApiError(error.data) &&
error.data.rawData.includes('reading policy from path') &&
error.data.rawData.includes('no such file or directory')
) {
return (
<div className="flex flex-col gap-4">
<Card className="max-w-2xl" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>ACL Policy Unavailable</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
<Card.Text>
The ACL policy is currently unavailable because the policy file does
not exist on the server. This usually indicates that Headscale is
running in <Code>file</Code> mode for ACLs, and the specified policy
file is missing.
</Card.Text>
</Card>
<Card className="max-w-2xl" variant="flat">
<Card.Text>
In order to resolve this issue, there are two possible actions you
can take:
</Card.Text>
<ul className="list-disc list-outside mt-2 ml-4 space-y-1 text-sm">
<li>
Create the ACL policy file at the specified path in your Headscale
configuration.
</li>
<li>
Alternatively, you can switch Headscale to use{' '}
<Code>database</Code> mode for ACLs by updating your Headscale
configuration. This will allow Headplane to manage the ACL policy
directly through the web interface.
</li>
</ul>
</Card>
</div>
);
}
throw error;
}
+90 -86
View File
@@ -1,98 +1,102 @@
import { data, redirect } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import log from '~/utils/log';
import type { Route } from './+types/page';
import { data, redirect } from "react-router";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import log from "~/utils/log";
import type { Route } from "./+types/page";
export async function loginAction({ request, context }: Route.LoaderArgs) {
const formData = await request.formData();
const apiKey = formData.has('api_key')
? String(formData.get('api_key'))
: undefined;
const formData = await request.formData();
const apiKey = formData.has("api_key") ? String(formData.get("api_key")) : undefined;
if (apiKey === undefined) {
log.warn('auth', 'Request made without API key');
log.warn(
'auth',
'If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly',
);
throw data('Missing `api_key`', { status: 400 });
}
if (apiKey === undefined) {
log.warn("auth", "Request made without API key");
log.warn(
"auth",
"If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly",
);
throw data("Missing `api_key`", { status: 400 });
}
if (apiKey.length === 0) {
log.warn('auth', 'Request made with empty API key');
log.warn(
'auth',
'If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly',
);
throw data('Received an empty `api_key`', { status: 400 });
}
if (apiKey.length === 0) {
log.warn("auth", "Request made with empty API key");
log.warn(
"auth",
"If this is unexpected, ensure your reverse proxy (if applicable) is configured correctly",
);
throw data("Received an empty `api_key`", { status: 400 });
}
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const apiKeys = await api.getApiKeys();
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const apiKeys = await api.getApiKeys();
console.log(apiKeys);
// We don't need to check for 0 API keys because this request cannot
// be authenticated correctly without an API key
const lookup = apiKeys.find((key) => apiKey.startsWith(key.prefix));
if (!lookup) {
return {
success: false,
message: 'API key was not found in the Headscale database',
};
}
// We don't need to check for 0 API keys because this request cannot
// be authenticated correctly without an API key
//
// 0.28.0 pointlessly added asterisks to the prefixes of API keys, which is
// the dumbest thing I've ever seen.
const lookup = apiKeys.find((key) => apiKey.startsWith(key.prefix.replaceAll("*", "")));
if (!lookup) {
return {
success: false,
message: "API key was not found in the Headscale database",
};
}
if (lookup.expiration === null || lookup.expiration === undefined) {
log.error('auth', 'Got an API key without an expiration');
throw data('API key is malformed', { status: 500 });
}
if (lookup.expiration === null || lookup.expiration === undefined) {
log.error("auth", "Got an API key without an expiration");
throw data("API key is malformed", { status: 500 });
}
const expiry = new Date(lookup.expiration);
if (expiry.getTime() < Date.now()) {
return {
success: false,
message: 'API key has expired',
};
}
const expiry = new Date(lookup.expiration);
if (expiry.getTime() < Date.now()) {
return {
success: false,
message: "API key has expired",
};
}
const expiresDays = Math.round(
(expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24,
);
const expiresDays = Math.round((expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24);
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.createSession(
{
api_key: apiKey,
user: {
subject: 'unknown-non-oauth',
name: `${lookup.prefix}...`,
email: `expires@${expiresDays.toString()}-days`,
},
},
expiry.getTime() - Date.now(),
),
},
});
} catch (error) {
if (error instanceof ResponseError) {
// TODO: What in gods name is wrong with the headscale API?
if (
error.status === 401 ||
error.status === 403 ||
(error.status === 500 && error.response.trim() === 'Unauthorized')
) {
return {
success: false,
message: 'API key is invalid (it may be incorrect or expired)',
};
}
}
return redirect("/machines", {
headers: {
"Set-Cookie": await context.sessions.createSession(
{
api_key: apiKey,
user: {
subject: "unknown-non-oauth",
name: `${lookup.prefix}...`,
email: `expires@${expiresDays.toString()}-days`,
},
},
expiry.getTime() - Date.now(),
),
},
});
} catch (error) {
// Check if this is a React Router DataWithResponseInit wrapping a Headscale API error
if (isDataWithApiError(error)) {
const apiError = error.data;
// TODO: What in gods name is wrong with the headscale API?
if (
apiError.statusCode === 401 ||
apiError.statusCode === 403 ||
(apiError.statusCode === 500 && apiError.rawData.trim() === "Unauthorized")
) {
return {
success: false,
message: "API key is invalid (it may be incorrect or expired)",
};
}
}
log.error('auth', 'Error while validating API key: %s', error);
log.debug('auth', 'Error details: %o', error);
return {
success: false,
message: 'Error while validating API key (see logs for details)',
};
}
log.error("auth", "Error while validating API key: %s", error);
log.debug("auth", "Error details: %o", error);
return {
success: false,
message: "Error while validating API key (see logs for details)",
};
}
}
+119 -111
View File
@@ -1,123 +1,131 @@
import { AlertCircle } from 'lucide-react';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Link from '~/components/Link';
import { OidcConnectorError } from '~/server/web/oidc-connector';
import { AlertCircle, CloudOff } from "lucide-react";
export function OidcConfigErrorNotice({
errors,
}: {
errors: OidcConnectorError[];
}) {
return (
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Authentication Error</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
<Card.Text className="text-sm">
The OpenID Connect (OIDC) Single Sign-On (SSO) configuration has issues:{' '}
<ul className="list-disc list-inside mt-2 mb-1">
{mapOidcErrorsToMessages(errors).map((code) => (
<li key={code.key}>{code.node}</li>
))}
</ul>{' '}
<Link
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#help"
>
Learn more
</Link>
</Card.Text>
</Card>
);
import Card from "~/components/Card";
import Code from "~/components/Code";
import Link from "~/components/Link";
import { OidcConnectorError } from "~/server/web/oidc-connector";
export function OidcDiscoveryFailedNotice() {
return (
<Card className="m-4 mb-4 max-w-md border border-yellow-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-yellow-500">SSO Temporarily Unavailable</Card.Title>
<CloudOff className="mb-2 h-6 w-6 text-yellow-500" />
</div>
<Card.Text className="text-sm">
Unable to reach the identity provider. Single Sign-On will be available once the provider is
reachable again. You can still sign in with an API key.
</Card.Text>
</Card>
);
}
export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[] }) {
return (
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Authentication Error</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text className="text-sm">
The OpenID Connect (OIDC) Single Sign-On (SSO) configuration has issues:{" "}
<ul className="mt-2 mb-1 list-inside list-disc">
{mapOidcErrorsToMessages(errors).map((code) => (
<li key={code.key}>{code.node}</li>
))}
</ul>{" "}
<Link
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#troubleshooting"
>
Learn more
</Link>
</Card.Text>
</Card>
);
}
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
const messages: {
key: string;
node: React.ReactNode;
}[] = [];
const messages: {
key: string;
node: React.ReactNode;
}[] = [];
for (const error of errors) {
switch (error) {
case 'INVALID_API_KEY':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The provided API key for OIDC authentication is invalid. Ensure
that <Code>oidc.headscale_api_key</Code> is a valid API key.
</Card.Text>
),
});
break;
for (const error of errors) {
switch (error) {
case "INVALID_API_KEY":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The provided API key for OIDC authentication is invalid. Ensure that{" "}
<Code>oidc.headscale_api_key</Code> is a valid API key.
</Card.Text>
),
});
break;
case 'MISSING_AUTHORIZATION_ENDPOINT':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured{' '}
<Code>authorization_endpoint</Code>. Ensure discovery URL or
manual configuration is correct.
</Card.Text>
),
});
break;
case "MISSING_AUTHORIZATION_ENDPOINT":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>authorization_endpoint</Code>.
Ensure discovery URL or manual configuration is correct.
</Card.Text>
),
});
break;
case 'MISSING_TOKEN_ENDPOINT':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured{' '}
<Code>token_endpoint</Code>. Ensure discovery URL or manual
configuration is correct.
</Card.Text>
),
});
break;
case "MISSING_TOKEN_ENDPOINT":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>token_endpoint</Code>. Ensure
discovery URL or manual configuration is correct.
</Card.Text>
),
});
break;
case 'MISSING_USERINFO_ENDPOINT':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured{' '}
<Code>user_endpoint</Code>. Ensure discovery URL or manual
configuration is correct.
</Card.Text>
),
});
break;
case "MISSING_USERINFO_ENDPOINT":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>user_endpoint</Code>. Ensure
discovery URL or manual configuration is correct.
</Card.Text>
),
});
break;
case 'MISSING_REQUIRED_CLAIMS':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provider does not support the <Code>sub</Code> claim,
which is required for authentication. Your OIDC provider may be
misconfigured.
</Card.Text>
),
});
break;
case "MISSING_REQUIRED_CLAIMS":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provider does not support the <Code>sub</Code> claim, which is required for
authentication. Your OIDC provider may be misconfigured.
</Card.Text>
),
});
break;
case 'UNKNOWN_ERROR':
messages.push({
key: error,
node: (
<Card.Text className="inline">
An unknown error occurred during OIDC configuration. Please check
the Headplane logs for more information.
</Card.Text>
),
});
break;
}
}
case "UNKNOWN_ERROR":
messages.push({
key: error,
node: (
<Card.Text className="inline">
An unknown error occurred during OIDC configuration. Please check the Headplane logs
for more information.
</Card.Text>
),
});
break;
}
}
return messages;
return messages;
}
+1 -1
View File
@@ -47,7 +47,7 @@ function getErrorMessage(code: string) {
case 'error_auth_failed':
return (
<Card.Text>
Authentication with the SSO provider failed. Please tray again later.
Authentication with the SSO provider failed. Please try again later.
Headplane logs may provide more information.
</Card.Text>
);
+136 -147
View File
@@ -1,167 +1,156 @@
import { AlertCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
data,
Form,
Link as RemixLink,
redirect,
useSearchParams,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import Link from '~/components/Link';
import { 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';
import { AlertCircle } from "lucide-react";
import { useEffect, useState } from "react";
import { Form, Link as RemixLink, redirect, useSearchParams } from "react-router";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Input from "~/components/Input";
import Link from "~/components/Link";
import { useLiveData } from "~/utils/live-data";
import type { Route } from "./+types/page";
import { loginAction } from "./action";
import { OidcConfigErrorNotice, OidcDiscoveryFailedNotice } from "./config-error";
import Logout from "./logout";
import { OidcErrorNotice } from "./oidc-error";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
return redirect('/machines');
} catch {}
try {
await context.sessions.auth(request);
return redirect("/machines");
} catch {}
const qp = new URL(request.url).searchParams;
const urlState = qp.get('s') ?? undefined;
const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined;
// MARK: This works because the OIDC connector will always return false
// for `isExclusive` if the OIDC config isn't usable.
if (context.oidcConnector?.isExclusive && urlState !== 'logout') {
return redirect('/oidc/start');
}
const oidcConnector = await context.oidcConnector?.get();
const isOidcConnectorEnabled = context.oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled
? context.oidcConnector!.errors
: [];
// MARK: This works because the OIDC connector will always return false
// for `isExclusive` if the OIDC config isn't usable.
if (oidcConnector?.isExclusive && urlState !== "logout") {
return redirect("/oidc/start");
}
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
};
const isOidcConnectorEnabled = oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled ? (oidcConnector?.errors ?? []) : [];
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
};
}
export const action = loginAction;
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
const {
isCookieSecureEnabled,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
} = loaderData;
const { isCookieSecureEnabled, isOidcConnectorEnabled, oidcErrorCodes, urlState } = loaderData;
const [showCookieWarning, setShowCookieWarning] = useState(false);
const [params] = useSearchParams();
const { pause } = useLiveData();
const [showCookieWarning, setShowCookieWarning] = useState(false);
const [params] = useSearchParams();
const { pause } = useLiveData();
useEffect(() => {
// This page does NOT need stale while revalidate logic
pause();
useEffect(() => {
// This page does NOT need stale while revalidate logic
pause();
if (isCookieSecureEnabled && window.location.protocol !== 'https:') {
setShowCookieWarning(true);
}
});
if (isCookieSecureEnabled && window.location.protocol !== "https:") {
setShowCookieWarning(true);
}
});
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (urlState !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete('s');
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (urlState !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete("s");
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
window.history.replaceState(null, '', newUrl);
}
}, [urlState, params]);
window.history.replaceState(null, "", newUrl);
}
}, [urlState, params]);
if (urlState === 'logout') {
return <Logout />;
}
if (urlState === "logout") {
return <Logout />;
}
return (
<div className="flex w-screen h-screen items-center justify-center">
<div>
{urlState?.startsWith('error_') ? (
<OidcErrorNotice code={urlState} />
) : oidcErrorCodes.length > 0 ? (
<OidcConfigErrorNotice errors={oidcErrorCodes} />
) : showCookieWarning ? (
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">
Configuration Issue
</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{showCookieWarning ? (
<Card.Text className="text-sm">
Headplane is configured to use secure cookies, but this site is
being served over an insecure connection and login will not work
correctly.{' '}
<Link
name="Headplane Common Issues"
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
>
Learn more.
</Link>
</Card.Text>
) : undefined}
</Card>
) : undefined}
<Card className="max-w-md m-4 sm:m-0">
<Card.Title>Welcome to Headplane</Card.Title>
<Form method="POST">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
<Input
className="mt-8 mb-2"
isRequired
label="API Key"
labelHidden
name="api_key"
placeholder="API Key"
type="password"
/>
{actionData?.success === false ? (
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
{actionData.message}
</Card.Text>
) : undefined}
<Button className="w-full" type="submit" variant="heavy">
Sign In
</Button>
</Form>
{isOidcConnectorEnabled ? (
<RemixLink to="/oidc/start">
<Button
className="w-full mt-2"
isDisabled={oidcErrorCodes.length > 0}
variant="light"
>
Single Sign-On
</Button>
</RemixLink>
) : undefined}
</Card>
</div>
</div>
);
return (
<div className="flex h-screen w-screen items-center justify-center">
<div>
{urlState?.startsWith("error_") ? (
<OidcErrorNotice code={urlState} />
) : oidcErrorCodes.includes("DISCOVERY_FAILED") ? (
<OidcDiscoveryFailedNotice />
) : oidcErrorCodes.length > 0 ? (
<OidcConfigErrorNotice errors={oidcErrorCodes} />
) : showCookieWarning ? (
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Configuration Issue</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
{showCookieWarning ? (
<Card.Text className="text-sm">
Headplane is configured to use secure cookies, but this site is being served over an
insecure connection and login will not work correctly.{" "}
<Link
name="Headplane Common Issues"
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
>
Learn more.
</Link>
</Card.Text>
) : undefined}
</Card>
) : undefined}
<Card className="m-4 max-w-md sm:m-0">
<Card.Title>Welcome to Headplane</Card.Title>
<Form method="POST">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate one by running{" "}
<Code>headscale apikeys create</Code> in your terminal.
</Card.Text>
<Input
className="mt-8 mb-2"
isRequired
label="API Key"
labelHidden
name="api_key"
placeholder="API Key"
type="password"
/>
{actionData?.success === false ? (
<Card.Text className="mb-2 text-sm text-red-600 dark:text-red-300">
{actionData.message}
</Card.Text>
) : undefined}
<Button className="w-full" type="submit" variant="heavy">
Sign In
</Button>
</Form>
{isOidcConnectorEnabled ? (
<RemixLink to="/oidc/start">
<Button
className="mt-2 w-full"
isDisabled={oidcErrorCodes.length > 0}
variant="light"
>
Single Sign-On
</Button>
</RemixLink>
) : undefined}
</Card>
</div>
</div>
);
}
+136 -130
View File
@@ -1,149 +1,155 @@
import { createHash } from 'node:crypto';
import { count, eq } from 'drizzle-orm';
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 { users } from '~/server/db/schema';
import { Roles } from '~/server/web/roles';
import log from '~/utils/log';
import type { OidcCookieState } from './oidc-start';
import { count, eq } from "drizzle-orm";
import { createHash } from "node:crypto";
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
import { ulid } from "ulidx";
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidcConnector?.isValid) {
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
import { users } from "~/server/db/schema";
import { Roles } from "~/server/web/roles";
import log from "~/utils/log";
import { createOidcStateCookie } from "~/utils/oidc-state";
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/login?s=error_no_query');
}
import type { Route } from "./+types/oidc-callback";
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300,
secure: context.config.server.cookie_secure,
domain: context.config.server.cookie_domain,
});
export async function loader({ request, context }: Route.LoaderArgs) {
const oidcConnector = await context.oidcConnector?.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
const oidcCookieState: OidcCookieState | null = await cookie.parse(
request.headers.get('Cookie'),
);
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect("/login?s=error_no_query");
}
if (oidcCookieState == null || typeof oidcCookieState !== 'object') {
log.warn('auth', 'Called OIDC callback without session cookie');
return redirect('/login?s=error_no_session');
}
const cookie = createOidcStateCookie(context.config);
const oidcCookieState = await cookie.parse(request.headers.get("Cookie"));
const { state, nonce } = oidcCookieState;
if (!state || !nonce) {
log.warn('auth', 'OIDC session cookie is missing required fields');
return redirect('/login?s=error_invalid_session');
}
if (oidcCookieState == null) {
log.warn("auth", "Called OIDC callback without session cookie");
return redirect("/login?s=error_no_session");
}
try {
const tokens = await oidc.authorizationCodeGrant(
context.oidcConnector.client,
request,
{
expectedState: state,
expectedNonce: nonce,
},
);
const { state, nonce, redirect_uri, verifier } = oidcCookieState;
if (!state || !nonce || !redirect_uri || !verifier) {
log.warn("auth", "OIDC session cookie is missing required fields");
return redirect("/login?s=error_invalid_session");
}
const claims = tokens.claims();
if (claims?.sub == null) {
log.warn('auth', 'No subject found in OIDC claims');
return redirect('/login?s=error_no_sub');
}
try {
const callbackUrl = new URL(redirect_uri);
const currentUrl = new URL(request.url);
callbackUrl.search = currentUrl.search;
const userInfo = await oidc.fetchUserInfo(
context.oidcConnector.client,
tokens.access_token,
claims.sub,
);
const tokens = await oidc.authorizationCodeGrant(oidcConnector.client, callbackUrl, {
expectedState: state,
expectedNonce: nonce,
...(oidcConnector.usePKCE ? { pkceCodeVerifier: verifier } : {}),
});
// 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 claims = tokens.claims();
if (claims?.sub == null) {
log.warn("auth", "No subject found in OIDC claims");
return redirect("/login?s=error_no_sub");
}
const picture =
context.config.oidc?.profile_picture_source === 'gravatar'
? (() => {
if (!userInfo.email) {
return undefined;
}
const userInfo = await oidc.fetchUserInfo(
oidcConnector.client,
tokens.access_token,
claims.sub,
);
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;
// 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 [{ count: userCount }] = await context.db
.select({ count: count() })
.from(users)
.where(eq(users.caps, Roles.owner));
const picture =
context.config.oidc?.profile_picture_source === "gravatar"
? (() => {
if (!userInfo.email) {
return undefined;
}
await context.db
.insert(users)
.values({
id: ulid(),
sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
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;
return redirect('/', {
headers: {
'Set-Cookie': await context.sessions.createSession({
api_key: context.oidcConnector.apiKey,
user: {
subject: claims.sub,
username,
name,
email: userInfo.email,
picture,
},
}),
},
});
} catch (error) {
if (error instanceof oidc.ResponseBodyError) {
log.error(
'auth',
'Got an OIDC response error body: %s',
JSON.stringify(error.cause),
);
}
const [{ count: userCount }] = await context.db
.select({ count: count() })
.from(users)
.where(eq(users.caps, Roles.owner));
if (error instanceof oidc.AuthorizationResponseError) {
log.error(
'auth',
'Got an OIDC authorization response error: %s',
error.error,
);
}
await context.db
.insert(users)
.values({
id: ulid(),
sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
if (error instanceof oidc.WWWAuthenticateChallengeError) {
log.error('auth', 'Got an OIDC WWW-Authenticate challenge error');
}
return redirect("/", {
headers: {
"Set-Cookie": await context.sessions.createSession({
api_key: oidcConnector.apiKey,
user: {
subject: claims.sub,
username,
name,
email: userInfo.email,
picture,
},
}),
},
});
} catch (error) {
if (error instanceof oidc.ResponseBodyError) {
log.error("auth", "Got an OIDC response error body: %s", JSON.stringify(error.cause));
return redirect('/login?s=error_auth_failed');
}
// Check for PKCE-related errors
if (
error.error.toLowerCase().includes("code_verifier") ||
error.error.toLowerCase().includes("code verifier") ||
error.error.toLowerCase().includes("pkce")
) {
log.error(
"auth",
"PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s",
oidcConnector.usePKCE,
);
if (!oidcConnector.usePKCE) {
log.error(
"auth",
"Consider setting oidc.use_pkce=true in your configuration if your provider requires PKCE",
);
}
}
} else if (error instanceof oidc.AuthorizationResponseError) {
log.error("auth", "Got an OIDC authorization response error: %s", error.error);
} else if (error instanceof oidc.WWWAuthenticateChallengeError) {
log.error("auth", "Got an OIDC WWW-Authenticate challenge error");
} else if (error instanceof oidc.ClientError) {
log.error(
"auth",
"Got an OIDC authorization client error: %s",
error.cause instanceof Error ? error.cause.message : String(error.cause),
);
} else {
log.error(
"auth",
"Got an OIDC error: %s",
error instanceof Error && error.cause ? JSON.stringify(error.cause) : String(error),
);
}
return redirect("/login?s=error_auth_failed");
}
}
+73 -72
View File
@@ -1,80 +1,81 @@
import * as oidc from 'openid-client';
import {
createCookie,
data,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import type { LoadContext } from '~/server';
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
export interface OidcCookieState {
nonce: string;
state: string;
import { HeadplaneConfig } from "~/server/config/config-schema";
import { createOidcStateCookie } from "~/utils/oidc-state";
import type { Route } from "./+types/oidc-start";
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
return redirect("/");
} catch {}
const oidcConnector = await context.oidcConnector?.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
const cookie = createOidcStateCookie(context.config);
const redirect_uri = getRedirectUri(context.config, request);
const nonce = oidc.randomNonce();
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(oidcConnector.client, {
...oidcConnector.extraParams,
scope: oidcConnector.scope,
redirect_uri,
state,
nonce,
...(oidcConnector.usePKCE
? {
code_challenge_method: "S256",
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
}
: {}),
});
return redirect(url.href, {
status: 302,
headers: {
"Set-Cookie": await cookie.serialize({
state,
nonce,
verifier,
redirect_uri,
}),
},
});
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try {
await context.sessions.auth(request);
return redirect('/');
} catch {}
function getRedirectUri(config: HeadplaneConfig, req: Request): string {
if (config.server.base_url != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.server.base_url);
return url.href;
}
if (!context.oidcConnector?.isValid) {
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
if (config.oidc?.redirect_uri != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.oidc.redirect_uri);
return url.href;
}
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300,
secure: context.config.server.cookie_secure,
domain: context.config.server.cookie_domain,
});
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
let host = req.headers.get("Host");
if (!host) {
host = req.headers.get("X-Forwarded-Host");
}
const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
if (!host) {
throw data("Cannot determine redirect URI: no Host or X-Forwarded-Host header", {
status: 500,
});
}
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,
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;
const proto = req.headers.get("X-Forwarded-Proto");
url.protocol = proto ?? "http:";
url.host = host;
return url.href;
}
+23 -23
View File
@@ -1,18 +1,18 @@
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
SortableContext,
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Lock } from 'lucide-react';
import { useEffect, useState } from 'react';
import { type FetcherWithComponents, Form, useFetcher } from 'react-router';
import { Form } from 'react-router';
import Button from '~/components/Button';
import Input from '~/components/Input';
import TableList from '~/components/TableList';
@@ -37,18 +37,15 @@ export default function ManageDomains({
}, [searchDomains]);
return (
<div className="flex flex-col w-2/3">
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className="mb-4">
Set custom DNS search domains for your Tailnet. When using Magic DNS,
your tailnet domain is used as the first search domain.
</p>
<DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={(event) => {
setActiveId(null);
const { active, over } = event;
@@ -70,6 +67,9 @@ export default function ManageDomains({
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
>
<TableList>
{magic ? (
@@ -91,19 +91,19 @@ export default function ManageDomains({
>
{localDomains.map((sd, index) => (
<Domain
key={sd}
domain={sd}
id={index + 1}
isDisabled={isDisabled}
key={sd}
/>
))}
<DragOverlay adjustScale>
{activeId ? (
<Domain
isDragging
domain={localDomains[(activeId as number) - 1]}
id={(activeId as number) - 1}
isDisabled={isDisabled}
isDragging
/>
) : undefined}
</DragOverlay>
@@ -111,28 +111,28 @@ export default function ManageDomains({
{isDisabled ? undefined : (
<TableList.Item key="add-sd">
<Form
method="POST"
className="flex items-center justify-between w-full"
method="POST"
>
<input type="hidden" name="action_id" value="add_domain" />
<input name="action_id" type="hidden" value="add_domain" />
<Input
type="text"
className={cn(
'border-none font-mono p-0 text-sm',
'rounded-none focus:ring-0 w-full ml-1',
)}
placeholder="Search Domain"
label="Search Domain"
name="domain"
labelHidden
isRequired
label="Search Domain"
labelHidden
name="domain"
placeholder="Search Domain"
type="text"
/>
<Button
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-blue-500 dark:text-blue-400',
)}
type="submit"
>
Add
</Button>
@@ -164,11 +164,11 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
return (
<TableList.Item
ref={setNodeRef}
className={cn(
isSortableDragging ? 'opacity-50' : '',
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
)}
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
@@ -186,15 +186,15 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
</p>
{isDragging ? undefined : (
<Form method="POST">
<input type="hidden" name="action_id" value="remove_domain" />
<input type="hidden" name="domain" value={domain} />
<input name="action_id" type="hidden" value="remove_domain" />
<input name="domain" type="hidden" value={domain} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
+18 -17
View File
@@ -20,14 +20,14 @@ export default function ManageNS({
overrideLocalDns,
}: Props) {
return (
<div className="flex flex-col w-2/3">
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p>
Set the nameservers used by devices on the Tailnet to resolve DNS
queries.{' '}
<Link
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation"
to="https://tailscale.com/kb/1054/dns"
>
Learn more
</Link>
@@ -35,12 +35,12 @@ export default function ManageNS({
<div className="mt-4">
{Object.keys(nameservers).map((key) => (
<NameserverList
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
isGlobal={key === 'global'}
key={key}
name={key}
nameservers={nameservers}
overrideLocalDns={overrideLocalDns}
name={key}
/>
))}
@@ -66,16 +66,17 @@ function NameserverList({
name,
}: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name];
const submit = useSubmit();
if (list.length === 0) {
return null;
}
const submit = useSubmit();
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
{isGlobal ? (
<div className="flex items-center justify-between w-full">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-2">
<h2 className="text-md font-medium opacity-80">
Global Nameservers
</h2>
@@ -87,8 +88,8 @@ function NameserverList({
names outside the tailnet. When disabled (default), devices
will prefer their local DNS configuration.
<Link
to="https://tailscale.com/kb/1054/dns#global-nameservers"
name="Tailscale Global Nameservers Documentation"
to="https://tailscale.com/kb/1054/dns#global-nameservers"
>
Learn More
</Link>
@@ -96,11 +97,10 @@ function NameserverList({
</Tooltip>
<p>Override DNS servers</p>
<Switch
label="Override local DNS settings"
className="h-[15px] w-[23px] p-[2px]"
switchClassName="h-[9px] w-[9px]"
name="override_dns"
className="h-[15px] w-[23px] p-0.5"
defaultSelected={overrideLocalDns}
label="Override local DNS settings"
name="override_dns"
onChange={(v) => {
submit(
{
@@ -112,6 +112,7 @@ function NameserverList({
},
);
}}
switchClassName="h-[9px] w-[9px]"
/>
</div>
</div>
@@ -125,20 +126,20 @@ function NameserverList({
<TableList.Item key={ns}>
<p className="font-mono text-sm">{ns}</p>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_ns" />
<input type="hidden" name="ns" value={ns} />
<input name="action_id" type="hidden" value="remove_ns" />
<input name="ns" type="hidden" value={ns} />
<input
type="hidden"
name="split_name"
type="hidden"
value={isGlobal ? 'global' : name}
/>
<Button
isDisabled={isDisabled}
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
+13 -11
View File
@@ -13,14 +13,14 @@ interface Props {
export default function ManageRecords({ records, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p>
Headscale supports adding custom DNS records to your Tailnet. As of now,
only <Code>A</Code> and <Code>AAAA</Code> records are supported.{' '}
<Link
to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation"
to="https://headscale.net/stable/ref/dns"
>
Learn More
</Link>
@@ -32,7 +32,7 @@ export default function ManageRecords({ records, isDisabled }: Props) {
<p className="opacity-50 mx-auto">No DNS records found</p>
</TableList.Item>
) : (
records.map((record, index) => (
records.map((record) => (
<TableList.Item key={`${record.name}-${record.value}`}>
<div className="flex gap-2 items-center w-full">
<p
@@ -43,22 +43,24 @@ export default function ManageRecords({ records, isDisabled }: Props) {
>
{record.type}
</p>
<div className="grid grid-cols-2 gap-2 w-full">
<p className="font-mono text-sm">{record.name}</p>
<p className="font-mono text-sm">{record.value}</p>
<div className="flex flex-col sm:flex-row sm:gap-2 flex-1 min-w-0">
<p className="font-mono text-sm truncate">{record.name}</p>
<p className="font-mono text-sm truncate opacity-70 sm:opacity-100">
{record.value}
</p>
</div>
</div>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_record" />
<input type="hidden" name="record_name" value={record.name} />
<input type="hidden" name="record_type" value={record.type} />
<input name="action_id" type="hidden" value="remove_record" />
<input name="record_name" type="hidden" value={record.name} />
<input name="record_type" type="hidden" value={record.type} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
+7 -7
View File
@@ -9,21 +9,21 @@ interface Props {
export default function RenameTailnet({ name, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3 gap-y-4">
<div className="flex flex-col w-full sm:w-2/3 gap-y-4">
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
<p>
This is the base domain name of your Tailnet. Devices are accessible at{' '}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
isReadOnly
labelHidden
className="w-3/5 font-medium text-sm"
isReadOnly
label="Tailnet name"
value={name}
labelHidden
onFocus={(event) => {
event.target.select();
}}
value={name}
/>
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
@@ -33,13 +33,13 @@ export default function RenameTailnet({ name, isDisabled }: Props) {
Keep in mind that changing this can lead to all sorts of unexpected
behavior and may break existing devices in your tailnet.
</Dialog.Text>
<input type="hidden" name="action_id" value="rename_tailnet" />
<input name="action_id" type="hidden" value="rename_tailnet" />
<Input
defaultValue={name}
isRequired
label="Tailnet name"
placeholder="ts.net"
defaultValue={name}
name="new_name"
placeholder="ts.net"
/>
</Dialog.Panel>
</Dialog>
+6 -6
View File
@@ -84,20 +84,20 @@ export default function Page() {
tailnet.
</Notice>
)}
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
<RenameTailnet isDisabled={isDisabled} name={data.baseDomain} />
<ManageNS
nameservers={allNs}
isDisabled={isDisabled}
nameservers={allNs}
overrideLocalDns={data.overrideDns}
/>
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
<ManageRecords isDisabled={isDisabled} records={data.extraRecords} />
<ManageDomains
searchDomains={data.searchDomains}
isDisabled={isDisabled}
magic={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
/>
<div className="flex flex-col w-2/3">
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="mb-4">
Automatically register domain names for each device on the tailnet.
@@ -108,7 +108,7 @@ export default function Page() {
</Code>{' '}
when Magic DNS is enabled.
</p>
<ToggleMagic isEnabled={data.magicDns} isDisabled={isDisabled} />
<ToggleMagic isDisabled={isDisabled} isEnabled={data.magicDns} />
</div>
</div>
);
+187 -201
View File
@@ -1,228 +1,214 @@
import { ChevronDown, Copy } from 'lucide-react';
import { useMemo } from 'react';
import { Link } from 'react-router';
import Chip from '~/components/Chip';
import Menu from '~/components/Menu';
import StatusCircle from '~/components/StatusCircle';
import { ExitNodeTag } from '~/components/tags/ExitNode';
import { ExpiryTag } from '~/components/tags/Expiry';
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
import { SubnetTag } from '~/components/tags/Subnet';
import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH';
import type { User } from '~/types';
import cn from '~/utils/cn';
import * as hinfo from '~/utils/host-info';
import { PopulatedNode } from '~/utils/node-info';
import toast from '~/utils/toast';
import MenuOptions from './menu';
import { ChevronDown, Copy } from "lucide-react";
import { useMemo } from "react";
import { Link } from "react-router";
import type { User } from "~/types";
import Chip from "~/components/Chip";
import Menu from "~/components/Menu";
import StatusCircle from "~/components/StatusCircle";
import { ExitNodeTag } from "~/components/tags/ExitNode";
import { ExpiryTag } from "~/components/tags/Expiry";
import { HeadplaneAgentTag } from "~/components/tags/HeadplaneAgent";
import { SubnetTag } from "~/components/tags/Subnet";
import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
import cn from "~/utils/cn";
import * as hinfo from "~/utils/host-info";
import { PopulatedNode } from "~/utils/node-info";
import { formatTimeDelta } from "~/utils/time";
import toast from "~/utils/toast";
import MenuOptions from "./menu";
interface Props {
node: PopulatedNode;
users: User[];
isAgent?: boolean;
magic?: string;
isDisabled?: boolean;
existingTags?: string[];
node: PopulatedNode;
users: User[];
isAgent?: boolean;
magic?: string;
isDisabled?: boolean;
existingTags?: string[];
supportsNodeOwnerChange: boolean;
}
export default function MachineRow({
node,
users,
isAgent,
magic,
isDisabled,
existingTags,
node,
users,
isAgent,
magic,
isDisabled,
existingTags,
supportsNodeOwnerChange,
}: Props) {
const uiTags = useMemo(() => {
const tags = uiTagsForNode(node, isAgent);
return tags;
}, [node, isAgent]);
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
const ipOptions = useMemo(() => {
if (magic) {
return [...node.ipAddresses, `${node.givenName}.${magic}`];
}
const ipOptions = useMemo(() => {
if (magic) {
return [...node.ipAddresses, `${node.givenName}.${magic}`];
}
return node.ipAddresses;
}, [magic, node.ipAddresses]);
return node.ipAddresses;
}, [magic, node.ipAddresses]);
return (
<tr
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
key={node.id}
>
<td className="pl-0.5 py-2 focus-within:ring-3">
<Link
className={cn('group/link h-full focus:outline-hidden')}
to={`/machines/${node.id}`}
>
<p
className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'dark:group-hover/link:text-blue-400',
)}
>
{node.givenName}
</p>
<p className="text-sm opacity-50">
{node.user.name ||
node.user.displayName ||
node.user.email ||
node.user.id}
</p>
<div className="flex gap-1 flex-wrap mt-1.5">
{mapTagsToComponents(node, uiTags)}
{node.validTags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</Link>
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{node.ipAddresses[0]}
<Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses">
<ChevronDown className="w-4 h-4" />
</Menu.IconButton>
<Menu.Panel
onAction={async (key) => {
await navigator.clipboard.writeText(key.toString());
toast('Copied IP address to clipboard');
}}
>
<Menu.Section>
{ipOptions.map((ip) => (
<Menu.Item key={ip} textValue={ip}>
<div
className={cn(
'flex items-center justify-between',
'text-sm w-full gap-x-6',
)}
>
{ip}
<Copy className="w-3 h-3" />
</div>
</Menu.Item>
))}
</Menu.Section>
</Menu.Panel>
</Menu>
</div>
</td>
{/* We pass undefined when agents are not enabled */}
{isAgent !== undefined ? (
<td className="py-2">
{node.hostInfo !== undefined ? (
<>
<p className="leading-snug">
{hinfo.getTSVersion(node.hostInfo)}
</p>
<p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(node.hostInfo)}
</p>
</>
) : (
<p className="text-sm opacity-50">Unknown</p>
)}
</td>
) : undefined}
<td className="py-2">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-headplane-600 dark:text-headplane-300',
)}
>
<StatusCircle
className="w-4 h-4"
isOnline={node.online && !node.expired}
/>
<p suppressHydrationWarning>
{node.online && !node.expired
? 'Connected'
: new Date(node.lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
existingTags={existingTags}
isDisabled={isDisabled}
magic={magic}
node={node}
users={users}
/>
</td>
</tr>
);
return (
<tr className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" key={node.id}>
<td className="py-2 pl-0.5 focus-within:ring-3">
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
<p
className={cn(
"font-semibold leading-snug",
"group-hover/link:text-blue-600",
"dark:group-hover/link:text-blue-400",
)}
>
{node.givenName}
</p>
<p className="text-sm opacity-50">
{node.user.name || node.user.displayName || node.user.email || node.user.id}
</p>
<div className="mt-1.5 flex flex-wrap gap-1">
{mapTagsToComponents(node, uiTags)}
{node.tags?.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</Link>
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{node.ipAddresses[0]}
<Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses">
<ChevronDown className="h-4 w-4" />
</Menu.IconButton>
<Menu.Panel
onAction={async (key) => {
await navigator.clipboard.writeText(key.toString());
toast("Copied IP address to clipboard");
}}
>
<Menu.Section>
{ipOptions.map((ip) => (
<Menu.Item key={ip} textValue={ip}>
<div
className={cn("flex items-center justify-between", "text-sm w-full gap-x-6")}
>
{ip}
<Copy className="h-3 w-3" />
</div>
</Menu.Item>
))}
</Menu.Section>
</Menu.Panel>
</Menu>
</div>
</td>
{/* We pass undefined when agents are not enabled */}
{isAgent !== undefined ? (
<td className="py-2">
{node.hostInfo !== undefined ? (
<>
<p className="leading-snug">{hinfo.getTSVersion(node.hostInfo)}</p>
<p className="max-w-48 truncate text-sm opacity-50">
{hinfo.getOSInfo(node.hostInfo)}
</p>
</>
) : (
<p className="text-sm opacity-50">Unknown</p>
)}
</td>
) : undefined}
<td className="py-2">
<div className="flex items-start gap-x-1">
<StatusCircle className="mt-0.5 h-4 w-4" isOnline={node.online && !node.expired} />
<div>
<p
className={cn("text-sm", "text-headplane-600 dark:text-headplane-300")}
suppressHydrationWarning
>
{node.online && !node.expired
? "Connected"
: new Date(node.lastSeen).toLocaleString()}
</p>
{!(node.online && !node.expired) && (
<p className="text-xs opacity-50" suppressHydrationWarning>
{formatTimeDelta(new Date(node.lastSeen))}
</p>
)}
</div>
</div>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
existingTags={existingTags}
isDisabled={isDisabled}
magic={magic}
node={node}
users={users}
supportsNodeOwnerChange={supportsNodeOwnerChange}
/>
</td>
</tr>
);
}
export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
const uiTags: string[] = [];
if (node.expired) {
uiTags.push('expired');
}
const uiTags: string[] = [];
if (node.expired) {
uiTags.push("expired");
}
if (node.expiry === null) {
uiTags.push('no-expiry');
}
if (node.expiry === null) {
uiTags.push("no-expiry");
}
if (node.customRouting.exitRoutes.length > 0) {
if (node.customRouting.exitApproved) {
uiTags.push('exit-approved');
} else {
uiTags.push('exit-waiting');
}
}
if (node.customRouting.exitRoutes.length > 0) {
if (node.customRouting.exitApproved) {
uiTags.push("exit-approved");
} else {
uiTags.push("exit-waiting");
}
}
if (node.customRouting.subnetWaitingRoutes.length > 0) {
uiTags.push('subnet-waiting');
} else if (node.customRouting.subnetApprovedRoutes.length > 0) {
uiTags.push('subnet-approved');
}
if (node.customRouting.subnetWaitingRoutes.length > 0) {
uiTags.push("subnet-waiting");
} else if (node.customRouting.subnetApprovedRoutes.length > 0) {
uiTags.push("subnet-approved");
}
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
uiTags.push('tailscale-ssh');
}
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
uiTags.push("tailscale-ssh");
}
if (isAgent === true) {
uiTags.push('headplane-agent');
}
if (isAgent === true) {
uiTags.push("headplane-agent");
}
return uiTags;
return uiTags;
}
export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
return uiTags.map((tag) => {
switch (tag) {
case 'exit-approved':
case 'exit-waiting':
return <ExitNodeTag isEnabled={tag === 'exit-approved'} key={tag} />;
return uiTags.map((tag) => {
switch (tag) {
case "exit-approved":
case "exit-waiting":
return <ExitNodeTag isEnabled={tag === "exit-approved"} key={tag} />;
case 'subnet-approved':
case 'subnet-waiting':
return <SubnetTag isEnabled={tag === 'subnet-approved'} key={tag} />;
case "subnet-approved":
case "subnet-waiting":
return <SubnetTag isEnabled={tag === "subnet-approved"} key={tag} />;
case 'expired':
case 'no-expiry':
return (
<ExpiryTag
expiry={node.expiry ?? undefined}
key={tag}
variant={tag}
/>
);
case "expired":
case "no-expiry":
return <ExpiryTag expiry={node.expiry ?? undefined} key={tag} variant={tag} />;
case 'tailscale-ssh':
return <TailscaleSSHTag key={tag} />;
case "tailscale-ssh":
return <TailscaleSSHTag key={tag} />;
case 'headplane-agent':
return <HeadplaneAgentTag key={tag} />;
case "headplane-agent":
return <HeadplaneAgentTag key={tag} />;
default:
return null;
}
});
default:
return null;
}
});
}
+3 -1
View File
@@ -19,6 +19,7 @@ interface MenuProps {
isFullButton?: boolean;
isDisabled?: boolean;
existingTags?: string[];
supportsNodeOwnerChange: boolean;
}
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
@@ -30,6 +31,7 @@ export default function MachineMenu({
isFullButton,
isDisabled,
existingTags,
supportsNodeOwnerChange,
}: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
const supportsTailscaleSSH =
@@ -164,7 +166,7 @@ export default function MachineMenu({
<Menu.Item key="rename">Edit machine name</Menu.Item>
<Menu.Item key="routes">Edit route settings</Menu.Item>
<Menu.Item key="tags">Edit ACL tags</Menu.Item>
<Menu.Item key="move">Change owner</Menu.Item>
{supportsNodeOwnerChange && <Menu.Item key="move">Change owner</Menu.Item>}
</Menu.Section>
<Menu.Section>
<Menu.Item key="expire" textValue="Expire">
+2 -2
View File
@@ -11,11 +11,11 @@ interface MoveProps {
}
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
const [userId, setUserId] = useState<Key | null>(null);
const [userId, setUserId] = useState<Key | null>(machine.user.id);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Panel isDisabled={userId === machine.user.id}>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
+5 -1
View File
@@ -20,10 +20,12 @@ export default function NewMachine(data: NewMachineProps) {
const [mkey, setMkey] = useState('');
const navigate = useNavigate();
const isMkeyInvalid = mkey.length > 0 && mkey.length !== 24;
return (
<>
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
<Dialog.Panel isDisabled={mkey.length < 1}>
<Dialog.Panel isDisabled={mkey.length !== 24}>
<Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4">
The machine key is given when you run{' '}
@@ -32,6 +34,8 @@ export default function NewMachine(data: NewMachineProps) {
</Dialog.Text>
<input name="action_id" type="hidden" value="register" />
<Input
errorMessage="Machine key must be exactly 24 characters"
isInvalid={isMkeyInvalid}
isRequired
label="Machine Key"
name="register_key"
+133 -98
View File
@@ -1,106 +1,141 @@
import { Plus, TagsIcon, X } from 'lucide-react';
import { useMemo, useState } from 'react';
import Button from '~/components/Button';
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import Select from '~/components/Select';
import TableList from '~/components/TableList';
import type { Machine } from '~/types';
import cn from '~/utils/cn';
import { Plus, TagsIcon, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useFetcher } from "react-router";
import type { Machine } from "~/types";
import Button from "~/components/Button";
import Dialog from "~/components/Dialog";
import Link from "~/components/Link";
import Select from "~/components/Select";
import TableList from "~/components/TableList";
import cn from "~/utils/cn";
interface TagsProps {
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
existingTags?: string[];
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
existingTags?: string[];
}
export default function Tags({
machine,
isOpen,
setIsOpen,
existingTags,
}: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState('tag:');
const tagIsInvalid = useMemo(() => {
return tag.length === 0 || !tag.startsWith('tag:') || tags.includes(tag);
}, [tag, tags]);
export default function Tags({ machine, isOpen, setIsOpen, existingTags }: TagsProps) {
const fetcher = useFetcher();
const submittingRef = useRef(false);
const [tags, setTags] = useState([...machine.tags]);
const [tag, setTag] = useState("tag:");
const tagIsInvalid = useMemo(() => {
return tag.length === 0 || !tag.startsWith("tag:") || tags.includes(tag);
}, [tag, tags]);
const validNodeTags = useMemo(() => {
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
}, [tags]);
const validNodeTags = useMemo(() => {
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
}, [tags]);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
<Dialog.Text>
ACL tags can be used to reference machines in your ACL policies. See
the{' '}
<Link
name="Tailscale documentation"
to="https://tailscale.com/kb/1068/acl-tags"
>
Tailscale documentation
</Link>{' '}
for more information.
</Dialog.Text>
<input name="action_id" type="hidden" value="update_tags" />
<input name="node_id" type="hidden" value={machine.id} />
<input name="tags" type="hidden" value={tags.join(',')} />
<TableList className="mt-4">
{tags.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<TagsIcon />
<p className="font-semibold">No tags are set on this machine</p>
</TableList.Item>
) : (
tags.map((item) => (
<TableList.Item className="font-mono" id={item} key={item}>
{item}
<Button
className="rounded-md p-0.5"
onPress={() => {
setTags(tags.filter((tag) => tag !== item));
}}
>
<X className="p-1" />
</Button>
</TableList.Item>
))
)}
</TableList>
const error = fetcher.data && !fetcher.data.success ? fetcher.data.error : null;
<div className="flex items-center gap-2 mt-2">
<Select
allowsCustomValue
aria-label="Add a tag"
className="w-full"
inputValue={tag}
isInvalid={tag.length > 0 && tagIsInvalid}
onInputChange={setTag}
placeholder="tag:example"
>
{validNodeTags.map((nodeTag) => {
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
})}
</Select>
<Button
className={cn(
'rounded-md p-1',
tagIsInvalid && 'opacity-50 cursor-not-allowed',
)}
isDisabled={tagIsInvalid}
onPress={() => {
setTags([...tags, tag]);
setTag('tag:');
}}
>
<Plus className="p-1" size={30} />
</Button>
</div>
</Dialog.Panel>
</Dialog>
);
useEffect(() => {
if (fetcher.data?.success) {
submittingRef.current = false;
setIsOpen(false);
}
if (fetcher.state === "idle" && fetcher.data && !fetcher.data.success) {
submittingRef.current = false;
}
}, [fetcher.data, fetcher.state]);
useEffect(() => {
if (isOpen) {
setTags([...machine.tags]);
}
}, [isOpen]);
return (
<Dialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open && submittingRef.current) return;
setIsOpen(open);
}}
>
<Dialog.Panel
onSubmit={(event) => {
event.preventDefault();
submittingRef.current = true;
const form = new FormData();
form.set("action_id", "update_tags");
form.set("node_id", machine.id);
form.set("tags", tags.filter((t) => t !== "").join(","));
fetcher.submit(form, { method: "POST" });
}}
isDisabled={fetcher.state !== "idle"}
>
<Dialog.Title>Edit ACL tags for {machine.givenName}</Dialog.Title>
<Dialog.Text>
ACL tags can be used to reference machines in your ACL policies. See the{" "}
<Link name="Tailscale documentation" to="https://tailscale.com/kb/1068/acl-tags">
Tailscale documentation
</Link>{" "}
for more information.
</Dialog.Text>
{error ? (
<p className="mt-2 rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/20 dark:text-red-400">
{error}
</p>
) : null}
<TableList className="mt-4">
{tags.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<TagsIcon />
<p className="font-semibold">No tags are set on this machine</p>
</TableList.Item>
) : (
tags.map((item) => (
<TableList.Item className="font-mono" id={item} key={item}>
{item}
<Button
className="rounded-md p-0.5"
onPress={() => {
setTags(tags.filter((tag) => tag !== item));
}}
>
<X className="p-1" />
</Button>
</TableList.Item>
))
)}
</TableList>
<div className="mt-2 flex items-center gap-2">
<Select
allowsCustomValue
aria-label="Add a tag"
className="w-full"
inputValue={tag}
isInvalid={tag.length > 0 && tagIsInvalid}
onInputChange={setTag}
placeholder="tag:example"
>
{validNodeTags.map((nodeTag) => {
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
})}
</Select>
<Button
className={cn("rounded-md p-1", tagIsInvalid && "opacity-50 cursor-not-allowed")}
isDisabled={tagIsInvalid}
onPress={() => {
setTags([...tags, tag]);
setTag("tag:");
}}
>
<Plus className="p-1" size={30} />
</Button>
</div>
<p className="mt-2 text-sm opacity-50">
Not seeing the tags you expect? Tags need to be defined in your access control policy
before they can be assigned to machines.
</p>
</Dialog.Panel>
</Dialog>
);
}
+163 -150
View File
@@ -1,178 +1,191 @@
import { data, redirect } from 'react-router';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/machine';
import { data, redirect } from "react-router";
import { isDataWithApiError } from "~/server/headscale/api/error-client";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/machine";
export async function machineAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_machines,
);
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.write_machines);
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(session.api_key);
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(session.api_key);
const action = formData.get('action_id')?.toString();
if (!action) {
throw data('Missing `action_id` in the form data.', {
status: 400,
});
}
const action = formData.get("action_id")?.toString();
if (!action) {
throw data("Missing `action_id` in the form data.", {
status: 400,
});
}
// Fast track register since it doesn't require an existing machine
if (action === 'register') {
if (!check) {
throw data('You do not have permission to manage machines', {
status: 403,
});
}
// Fast track register since it doesn't require an existing machine
if (action === "register") {
if (!check) {
throw data("You do not have permission to manage machines", {
status: 403,
});
}
const registrationKey = formData.get('register_key')?.toString();
if (!registrationKey) {
throw data('Missing `register_key` in the form data.', {
status: 400,
});
}
const registrationKey = formData.get("register_key")?.toString();
if (!registrationKey) {
throw data("Missing `register_key` in the form data.", {
status: 400,
});
}
const user = formData.get('user')?.toString();
if (!user) {
throw data('Missing `user` in the form data.', {
status: 400,
});
}
const user = formData.get("user")?.toString();
if (!user) {
throw data("Missing `user` in the form data.", {
status: 400,
});
}
const node = await api.registerNode(user, registrationKey);
return redirect(`/machines/${node.id}`);
}
const node = await api.registerNode(user, registrationKey);
return redirect(`/machines/${node.id}`);
}
// Check if the user has permission to manage this machine
const nodeId = formData.get('node_id')?.toString();
if (!nodeId) {
throw data('Missing `node_id` in the form data.', {
status: 400,
});
}
// Check if the user has permission to manage this machine
const nodeId = formData.get("node_id")?.toString();
if (!nodeId) {
throw data("Missing `node_id` in the form data.", {
status: 400,
});
}
const node = await api.getNode(nodeId);
if (!node) {
throw data(`Machine with ID ${nodeId} not found`, {
status: 404,
});
}
const node = await api.getNode(nodeId);
if (!node) {
throw data(`Machine with ID ${nodeId} not found`, {
status: 404,
});
}
if (
node.user.providerId?.split('/').pop() !== session.user.subject &&
!check
) {
throw data('You do not have permission to act on this machine', {
status: 403,
});
}
if (node.user.providerId?.split("/").pop() !== session.user.subject && !check) {
throw data("You do not have permission to act on this machine", {
status: 403,
});
}
switch (action) {
case 'rename': {
const newName = formData.get('name')?.toString();
if (!newName) {
throw data('Missing `name` in the form data.', {
status: 400,
});
}
switch (action) {
case "rename": {
const newName = formData.get("name")?.toString();
if (!newName) {
throw data("Missing `name` in the form data.", {
status: 400,
});
}
const name = String(formData.get('name'));
await api.renameNode(nodeId, name);
return { message: 'Machine renamed' };
}
const name = String(formData.get("name"));
await api.renameNode(nodeId, name);
return { message: "Machine renamed" };
}
case 'delete': {
await api.deleteNode(nodeId);
return redirect('/machines');
}
case "delete": {
await api.deleteNode(nodeId);
return redirect("/machines");
}
case 'expire': {
await api.expireNode(nodeId);
return { message: 'Machine expired' };
}
case "expire": {
await api.expireNode(nodeId);
return { message: "Machine expired" };
}
case 'update_tags': {
const tags = formData.get('tags')?.toString().split(',') ?? [];
if (tags.length === 0) {
throw data('Missing `tags` in the form data.', {
status: 400,
});
}
case "update_tags": {
const tags = formData.get("tags")?.toString().split(",") ?? [];
if (tags.length === 0) {
throw data("Missing `tags` in the form data.", {
status: 400,
});
}
await api.setNodeTags(
nodeId,
tags.map((tag) => tag.trim()).filter((tag) => tag !== ''),
);
return { message: 'Tags updated' };
}
try {
await api.setNodeTags(
nodeId,
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
);
case 'update_routes': {
const newApproved = node.approvedRoutes;
const routes = formData.get('routes')?.toString();
if (!routes) {
throw data('Missing `routes` in the form data.', {
status: 400,
});
}
return { success: true as const, message: "Tags updated" };
} catch (error) {
if (isDataWithApiError(error) && error.data.statusCode === 400) {
return data(
{
success: false as const,
error:
"One or more tags are not defined in your ACL policy. Please add them to your policy before assigning them to a machine.",
},
{ status: 400 },
);
}
const allRoutes = routes.split(',').map((route) => route.trim());
if (allRoutes.length === 0) {
throw data('No routes provided to update', {
status: 400,
});
}
throw error;
}
}
const enabled = formData.get('enabled')?.toString();
if (enabled === undefined) {
throw data('Missing `enabled` in the form data.', {
status: 400,
});
}
case "update_routes": {
const newApproved = node.approvedRoutes;
const routes = formData.get("routes")?.toString();
if (!routes) {
throw data("Missing `routes` in the form data.", {
status: 400,
});
}
if (enabled === 'true') {
for (const route of allRoutes) {
// If already approved, skip, otherwise add to approved
if (newApproved.includes(route)) {
continue;
}
const allRoutes = routes.split(",").map((route) => route.trim());
if (allRoutes.length === 0) {
throw data("No routes provided to update", {
status: 400,
});
}
newApproved.push(route);
}
} else {
for (const route of allRoutes) {
// If not approved, skip, otherwise remove from approved
if (!newApproved.includes(route)) {
continue;
}
const enabled = formData.get("enabled")?.toString();
if (enabled === undefined) {
throw data("Missing `enabled` in the form data.", {
status: 400,
});
}
const index = newApproved.indexOf(route);
if (index > -1) {
newApproved.splice(index, 1);
}
}
}
if (enabled === "true") {
for (const route of allRoutes) {
// If already approved, skip, otherwise add to approved
if (newApproved.includes(route)) {
continue;
}
await api.approveNodeRoutes(nodeId, newApproved);
return { message: 'Routes updated' };
}
newApproved.push(route);
}
} else {
for (const route of allRoutes) {
// If not approved, skip, otherwise remove from approved
if (!newApproved.includes(route)) {
continue;
}
case 'reassign': {
const user = formData.get('user_id')?.toString();
if (!user) {
throw data('Missing `user_id` in the form data.', {
status: 400,
});
}
const index = newApproved.indexOf(route);
if (index > -1) {
newApproved.splice(index, 1);
}
}
}
await api.setNodeUser(nodeId, user);
return { message: 'Machine reassigned' };
}
await api.approveNodeRoutes(nodeId, newApproved);
return { message: "Routes updated" };
}
default:
throw data('Invalid action', {
status: 400,
});
}
case "reassign": {
const user = formData.get("user_id")?.toString();
if (!user) {
throw data("Missing `user_id` in the form data.", {
status: 400,
});
}
await api.setNodeUser(nodeId, user);
return { message: "Machine reassigned" };
}
default:
throw data("Invalid action", {
status: 400,
});
}
}
+332 -384
View File
@@ -1,408 +1,356 @@
import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
import { useMemo, useState } from 'react';
import { data, Link as RemixLink } from 'react-router';
import Attribute from '~/components/Attribute';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Chip from '~/components/Chip';
import Link from '~/components/Link';
import StatusCircle from '~/components/StatusCircle';
import Tooltip from '~/components/Tooltip';
import cn from '~/utils/cn';
import { getOSInfo, getTSVersion } from '~/utils/host-info';
import { mapNodes, sortNodeTags } from '~/utils/node-info';
import type { Route } from './+types/machine';
import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
import MenuOptions from './components/menu';
import Routes from './dialogs/routes';
import { machineAction } from './machine-actions';
import { CheckCircle, CircleSlash, Info, UserCircle } from "lucide-react";
import { useMemo, useState } from "react";
import { data, Link as RemixLink } from "react-router";
import Attribute from "~/components/Attribute";
import Button from "~/components/Button";
import Card from "~/components/Card";
import Chip from "~/components/Chip";
import Link from "~/components/Link";
import StatusCircle from "~/components/StatusCircle";
import Tooltip from "~/components/Tooltip";
import cn from "~/utils/cn";
import { getOSInfo, getTSVersion } from "~/utils/host-info";
import { mapNodes, sortNodeTags } from "~/utils/node-info";
import type { Route } from "./+types/machine";
import { mapTagsToComponents, uiTagsForNode } from "./components/machine-row";
import MenuOptions from "./components/menu";
import Routes from "./dialogs/routes";
import { machineAction } from "./machine-actions";
export async function loader({ request, params, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
if (!params.id) {
throw new Error('No machine ID provided');
}
const 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 });
}
if (params.id.endsWith(".ico")) {
throw data(null, { status: 204 });
}
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
magic = context.hs.c.dns.base_domain;
}
}
const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
const node = nodes.find((node) => node.id === params.id);
const api = context.hsApi.getRuntimeClient(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([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup);
const tags = Array.from(
new Set([...node.validTags, ...node.forcedTags]),
).sort();
const lookup = await context.agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup);
const tags = [...node.tags].sort();
const supportsNodeOwnerChange = ! context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
return {
node: enhancedNode,
tags,
users,
magic,
agent: context.agents?.agentID(),
stats: lookup?.[enhancedNode.nodeKey],
existingTags: sortNodeTags(nodes),
};
return {
node: enhancedNode,
tags,
users,
magic,
agent: context.agents?.agentID(),
stats: lookup?.[enhancedNode.nodeKey],
existingTags: sortNodeTags(nodes),
supportsNodeOwnerChange: supportsNodeOwnerChange,
};
}
export const action = machineAction;
export default function Page({
loaderData: { node, tags, users, magic, agent, stats, existingTags },
loaderData: { node, tags, users, magic, agent, stats, existingTags, supportsNodeOwnerChange },
}: Route.ComponentProps) {
const [showRouting, setShowRouting] = useState(false);
const [showRouting, setShowRouting] = useState(false);
const uiTags = useMemo(() => {
const tags = uiTagsForNode(node, agent === node.nodeKey);
return tags;
}, [node, agent]);
const uiTags = useMemo(() => {
const tags = uiTagsForNode(node, agent === node.nodeKey);
return tags;
}, [node, agent]);
return (
<div>
<p className="mb-8 text-md">
<RemixLink className="font-medium" to="/machines">
All Machines
</RemixLink>
<span className="mx-2">/</span>
{node.givenName}
</p>
<div
className={cn(
'flex justify-between items-center pb-2',
'border-b border-headplane-100 dark:border-headplane-800',
)}
>
<span className="flex items-baseline gap-x-4 text-sm">
<h1 className="text-2xl font-medium">{node.givenName}</h1>
<StatusCircle className="w-4 h-4" isOnline={node.online} />
</span>
<MenuOptions
existingTags={existingTags}
isFullButton
magic={magic}
node={node}
users={users}
/>
</div>
<div className="flex gap-1 mb-4">
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
<span className="text-sm text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Managed by
<Tooltip>
<Info className="p-1" />
<Tooltip.Body>
By default, a machines permissions match its creators.
</Tooltip.Body>
</Tooltip>
</span>
<div className="flex items-center gap-x-2.5 mt-1">
<UserCircle />
{node.user.name ||
node.user.displayName ||
node.user.email ||
node.user.id}
</div>
</div>
<div className="p-2 pl-4">
<p className="text-sm text-headplane-600 dark:text-headplane-300">
Status
</p>
<div className="flex gap-1 mt-1 mb-8">
{mapTagsToComponents(node, uiTags)}
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</div>
</div>
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
<div className="flex items-center justify-between mb-4">
<p>
Subnets let you expose physical network routes onto Tailscale.{' '}
<Link
name="Tailscale Subnets Documentation"
to="https://tailscale.com/kb/1019/subnets"
>
Learn More
</Link>
</p>
<Button onPress={() => setShowRouting(true)}>Review</Button>
</div>
<Card
className={cn(
'w-full max-w-full grid sm:grid-cols-2',
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
)}
variant="flat"
>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Approved
<Tooltip>
<Info className="w-3.5 h-3.5" />
<Tooltip.Body>
Traffic to these routes are being routed through this machine.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
<span className="opacity-50"></span>
) : (
<ul className="leading-normal">
{node.customRouting.subnetApprovedRoutes.map((route) => (
<li key={route}>{route}</li>
))}
</ul>
)}
</div>
<Button
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
</div>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Awaiting Approval
<Tooltip>
<Info className="w-3.5 h-3.5" />
<Tooltip.Body>
This machine is advertising these routes, but they must be
approved before traffic will be routed to them.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
<span className="opacity-50"></span>
) : (
<ul className="leading-normal">
{node.customRouting.subnetWaitingRoutes.map((route) => (
<li key={route}>{route}</li>
))}
</ul>
)}
</div>
<Button
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
</div>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Exit Node
<Tooltip>
<Info className="w-3.5 h-3.5" />
<Tooltip.Body>
Whether this machine can act as an exit node for your tailnet.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{node.customRouting.exitRoutes.length === 0 ? (
<span className="opacity-50"></span>
) : node.customRouting.exitApproved ? (
<span className="flex items-center gap-x-1">
<CheckCircle className="w-3.5 h-3.5 text-green-700" />
Allowed
</span>
) : (
<span className="flex items-center gap-x-1">
<CircleSlash className="w-3.5 h-3.5 text-red-700" />
Awaiting Approval
</span>
)}
</div>
<Button
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
</div>
</Card>
<h2 className="text-xl font-medium">Machine Details</h2>
<p className="mb-4">
Information about this machines network. Used to debug connection
issues.
</p>
<Card
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12"
variant="flat"
>
<div className="flex flex-col gap-1">
<Attribute
name="Creator"
value={
node.user.name ||
node.user.displayName ||
node.user.email ||
node.user.id
}
/>
<Attribute name="Machine name" value={node.givenName} />
<Attribute
name="OS hostname"
tooltip="OS hostname is published by the machines operating system and is used as the default name for the machine."
value={node.name}
/>
{stats ? (
<>
<Attribute name="OS" value={getOSInfo(stats)} />
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
</>
) : undefined}
<Attribute
name="ID"
tooltip="ID for this machine. Used in the Headscale API."
value={node.id}
/>
<Attribute
isCopyable
name="Node key"
tooltip="Public key which uniquely identifies this machine."
value={node.nodeKey}
/>
<Attribute
name="Created"
value={new Date(node.createdAt).toLocaleString()}
/>
<Attribute
name="Last Seen"
value={
node.online
? 'Connected'
: new Date(node.lastSeen).toLocaleString()
}
/>
<Attribute
name="Key expiry"
value={
node.expiry !== null
? new Date(node.expiry).toLocaleString()
: 'Never'
}
/>
{magic ? (
<Attribute
isCopyable
name="Domain"
value={`${node.givenName}.${magic}`}
/>
) : undefined}
</div>
<div className="flex flex-col gap-1">
<p className="uppercase text-sm font-semibold opacity-75">
Addresses
</p>
<Attribute
isCopyable
name="Tailscale IPv4"
tooltip="This machines IPv4 address within your tailnet (your private Tailscale network)."
value={getIpv4Address(node.ipAddresses)}
/>
<Attribute
isCopyable
name="Tailscale IPv6"
tooltip="This machines IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
value={getIpv6Address(node.ipAddresses)}
/>
<Attribute
isCopyable
name="Short domain"
tooltip="Users of your tailnet can use this DNS short name to access this machine."
value={node.givenName}
/>
{magic ? (
<Attribute
isCopyable
name="Full domain"
tooltip="Users of your tailnet can use this DNS name to access this machine."
value={`${node.givenName}.${magic}`}
/>
) : undefined}
{stats ? (
<>
<p className="uppercase text-sm font-semibold opacity-75 mt-4">
Client Connectivity
</p>
<Attribute
name="Varies"
tooltip="Whether the machine is behind a difficult NAT that varies the machines IP address depending on the destination."
value={stats.NetInfo?.MappingVariesByDestIP ? 'Yes' : 'No'}
/>
<Attribute
name="Hairpinning"
tooltip="Whether the machine needs to traverse NATs with hairpinning."
value={stats.NetInfo?.HairPinning ? 'Yes' : 'No'}
/>
<Attribute
name="IPv6"
value={stats.NetInfo?.WorkingIPv6 ? 'Yes' : 'No'}
/>
<Attribute
name="UDP"
value={stats.NetInfo?.WorkingUDP ? 'Yes' : 'No'}
/>
<Attribute
name="UPnP"
value={stats.NetInfo?.UPnP ? 'Yes' : 'No'}
/>
<Attribute name="PCP" value={stats.NetInfo?.PCP ? 'Yes' : 'No'} />
<Attribute
name="NAT-PMP"
value={stats.NetInfo?.PMP ? 'Yes' : 'No'}
/>
</>
) : undefined}
</div>
</Card>
</div>
);
return (
<div>
<p className="text-md mb-8">
<RemixLink className="font-medium" to="/machines">
All Machines
</RemixLink>
<span className="mx-2">/</span>
{node.givenName}
</p>
<div
className={cn(
"flex justify-between items-center pb-2",
"border-b border-headplane-100 dark:border-headplane-800",
)}
>
<span className="flex items-baseline gap-x-4 text-sm">
<h1 className="text-2xl font-medium">{node.givenName}</h1>
<StatusCircle className="h-4 w-4" isOnline={node.online} />
</span>
<MenuOptions
existingTags={existingTags}
isFullButton
magic={magic}
node={node}
users={users}
supportsNodeOwnerChange={supportsNodeOwnerChange}
/>
</div>
<div className="mb-4 flex gap-1">
<div className="border-headplane-100 dark:border-headplane-800 border-r p-2 pr-4">
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1 text-sm">
Managed by
<Tooltip>
<Info className="p-1" />
<Tooltip.Body>By default, a machines permissions match its creators.</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1 flex items-center gap-x-2.5">
<UserCircle />
{node.user.name || node.user.displayName || node.user.email || node.user.id}
</div>
</div>
<div className="p-2 pl-4">
<p className="text-headplane-600 dark:text-headplane-300 text-sm">Status</p>
<div className="mt-1 mb-8 flex gap-1">
{mapTagsToComponents(node, uiTags)}
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</div>
</div>
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
<h2 className="mt-8 text-xl font-medium">Subnets & Routing</h2>
<div className="mb-4 flex items-center justify-between">
<p>
Subnets let you expose physical network routes onto Tailscale.{" "}
<Link name="Tailscale Subnets Documentation" to="https://tailscale.com/kb/1019/subnets">
Learn More
</Link>
</p>
<Button onPress={() => setShowRouting(true)}>Review</Button>
</div>
<Card
className={cn(
"w-full max-w-full grid sm:grid-cols-2",
"md:grid-cols-4 gap-8 mr-2 text-sm mb-8",
)}
variant="flat"
>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Approved
<Tooltip>
<Info className="h-3.5 w-3.5" />
<Tooltip.Body>
Traffic to these routes are being routed through this machine.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
<span className="opacity-50"></span>
) : (
<ul className="leading-normal">
{node.customRouting.subnetApprovedRoutes.map((route) => (
<li key={route}>{route}</li>
))}
</ul>
)}
</div>
<Button
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
</div>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Awaiting Approval
<Tooltip>
<Info className="h-3.5 w-3.5" />
<Tooltip.Body>
This machine is advertising these routes, but they must be approved before traffic
will be routed to them.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
<span className="opacity-50"></span>
) : (
<ul className="leading-normal">
{node.customRouting.subnetWaitingRoutes.map((route) => (
<li key={route}>{route}</li>
))}
</ul>
)}
</div>
<Button
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
</div>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
Exit Node
<Tooltip>
<Info className="h-3.5 w-3.5" />
<Tooltip.Body>
Whether this machine can act as an exit node for your tailnet.
</Tooltip.Body>
</Tooltip>
</span>
<div className="mt-1">
{node.customRouting.exitRoutes.length === 0 ? (
<span className="opacity-50"></span>
) : node.customRouting.exitApproved ? (
<span className="flex items-center gap-x-1">
<CheckCircle className="h-3.5 w-3.5 text-green-700" />
Allowed
</span>
) : (
<span className="flex items-center gap-x-1">
<CircleSlash className="h-3.5 w-3.5 text-red-700" />
Awaiting Approval
</span>
)}
</div>
<Button
className={cn("px-1.5 py-0.5 rounded-md mt-1.5", "text-blue-500 dark:text-blue-400")}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
</div>
</Card>
<h2 className="text-xl font-medium">Machine Details</h2>
<p className="mb-4">
Information about this machines network. Used to debug connection issues.
</p>
<Card
className="grid w-full max-w-full grid-cols-1 gap-y-2 sm:gap-x-12 lg:grid-cols-2"
variant="flat"
>
<div className="flex flex-col gap-1">
<Attribute
name="Creator"
value={node.user.name || node.user.displayName || node.user.email || node.user.id}
/>
<Attribute name="Machine name" value={node.givenName} />
<Attribute
name="OS hostname"
tooltip="OS hostname is published by the machines operating system and is used as the default name for the machine."
value={node.name}
/>
{stats ? (
<>
<Attribute name="OS" value={getOSInfo(stats)} />
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
</>
) : undefined}
<Attribute
name="ID"
tooltip="ID for this machine. Used in the Headscale API."
value={node.id}
/>
<Attribute
isCopyable
name="Node key"
tooltip="Public key which uniquely identifies this machine."
value={node.nodeKey}
/>
<Attribute name="Created" value={new Date(node.createdAt).toLocaleString()} />
<Attribute
name="Last Seen"
value={node.online ? "Connected" : new Date(node.lastSeen).toLocaleString()}
/>
<Attribute
name="Key expiry"
value={node.expiry !== null ? new Date(node.expiry).toLocaleString() : "Never"}
/>
{magic ? (
<Attribute isCopyable name="Domain" value={`${node.givenName}.${magic}`} />
) : undefined}
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold uppercase opacity-75">Addresses</p>
<Attribute
isCopyable
name="Tailscale IPv4"
tooltip="This machines IPv4 address within your tailnet (your private Tailscale network)."
value={getIpv4Address(node.ipAddresses)}
/>
<Attribute
isCopyable
name="Tailscale IPv6"
tooltip="This machines IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
value={getIpv6Address(node.ipAddresses)}
/>
<Attribute
isCopyable
name="Short domain"
tooltip="Users of your tailnet can use this DNS short name to access this machine."
value={node.givenName}
/>
{magic ? (
<Attribute
isCopyable
name="Full domain"
tooltip="Users of your tailnet can use this DNS name to access this machine."
value={`${node.givenName}.${magic}`}
/>
) : undefined}
{stats ? (
<>
<p className="mt-4 text-sm font-semibold uppercase opacity-75">Client Connectivity</p>
<Attribute
name="Varies"
tooltip="Whether the machine is behind a difficult NAT that varies the machines IP address depending on the destination."
value={stats.NetInfo?.MappingVariesByDestIP ? "Yes" : "No"}
/>
<Attribute
name="Hairpinning"
tooltip="Whether the machine needs to traverse NATs with hairpinning."
value={stats.NetInfo?.HairPinning ? "Yes" : "No"}
/>
<Attribute name="IPv6" value={stats.NetInfo?.WorkingIPv6 ? "Yes" : "No"} />
<Attribute name="UDP" value={stats.NetInfo?.WorkingUDP ? "Yes" : "No"} />
<Attribute name="UPnP" value={stats.NetInfo?.UPnP ? "Yes" : "No"} />
<Attribute name="PCP" value={stats.NetInfo?.PCP ? "Yes" : "No"} />
<Attribute name="NAT-PMP" value={stats.NetInfo?.PMP ? "Yes" : "No"} />
</>
) : undefined}
</div>
</Card>
</div>
);
}
function getIpv4Address(addresses: string[]) {
for (const address of addresses) {
if (address.startsWith('100.')) {
// Return the first CGNAT address
return address;
}
}
for (const address of addresses) {
if (address.startsWith("100.")) {
// Return the first CGNAT address
return address;
}
}
return '—';
return "—";
}
function getIpv6Address(addresses: string[]) {
for (const address of addresses) {
if (address.startsWith('fd')) {
// Return the first IPv6 address
return address;
}
}
for (const address of addresses) {
if (address.startsWith("fd")) {
// Return the first IPv6 address
return address;
}
}
return '—';
return "—";
}
+302 -57
View File
@@ -1,5 +1,7 @@
import { Info } from 'lucide-react';
import { ChevronDown, ChevronUp, Info, X } from 'lucide-react';
import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Input from '~/components/Input';
import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip';
import { Capabilities } from '~/server/web/roles';
@@ -46,6 +48,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats);
const supportsNodeOwnerChange = ! context.hsApi.clientHelpers.isAtleast("0.28.0-beta.1");
return {
populatedNodes,
@@ -61,16 +64,103 @@ export async function loader({ request, context }: Route.LoaderArgs) {
Capabilities.generate_authkeys,
),
subject: user.subject,
supportsNodeOwnerChange: supportsNodeOwnerChange,
};
}
export const action = machineAction;
type SortField = 'name' | 'ip' | 'version' | 'lastSeen';
export default function Page({ loaderData }: Route.ComponentProps) {
const [searchQuery, setSearchQuery] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const filteredAndSortedNodes = useMemo(() => {
const query = searchQuery.toLowerCase().trim();
let nodes = loaderData.populatedNodes.filter((node) => {
if (!query) return true;
if (node.givenName.toLowerCase().includes(query)) return true;
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query)))
return true;
return false;
});
nodes = [...nodes].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case 'name':
comparison = a.givenName.localeCompare(b.givenName);
break;
case 'ip': {
const getIPv4 = (addresses: string[]) =>
addresses.find((ip) => !ip.includes(':')) || addresses[0] || '';
const ipA = getIPv4(a.ipAddresses);
const ipB = getIPv4(b.ipAddresses);
if (!ipA.includes(':') && !ipB.includes(':')) {
const octetsA = ipA.split('.').map(Number);
const octetsB = ipB.split('.').map(Number);
for (let i = 0; i < 4; i++) {
if (octetsA[i] !== octetsB[i]) {
comparison = octetsA[i] - octetsB[i];
break;
}
}
} else {
comparison = ipA.localeCompare(ipB);
}
break;
}
case 'version': {
const versionA = a.hostInfo?.IPNVersion?.split('-')[0] || '0';
const versionB = b.hostInfo?.IPNVersion?.split('-')[0] || '0';
const partsA = versionA.split('.').map(Number);
const partsB = versionB.split('.').map(Number);
const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) {
const segA = partsA[i] || 0;
const segB = partsB[i] || 0;
if (segA !== segB) {
comparison = segA - segB;
break;
}
}
break;
}
case 'lastSeen':
if (a.online !== b.online) {
comparison = a.online ? 1 : -1;
break;
}
comparison =
new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime();
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
return nodes;
}, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('asc');
}
};
return (
<>
<div className="flex justify-between items-center mb-6">
<div className="flex flex-col w-2/3">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<div className="flex flex-col">
<h1 className="text-2xl font-medium mb-2">Machines</h1>
<p>
Manage the devices connected to your Tailnet.{' '}
@@ -89,61 +179,216 @@ export default function Page({ loaderData }: Route.ComponentProps) {
users={loaderData.users}
/>
</div>
<table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th className="uppercase text-xs font-bold pb-2">Name</th>
<th className="pb-2 w-1/4">
<div className="flex items-center gap-x-1">
<p className="uppercase text-xs font-bold">Addresses</p>
{loaderData.magic ? (
<Tooltip>
<Info className="w-4 h-4" />
<Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based on
their name and also at{' '}
<Code>
[name].
{loaderData.magic}
</Code>
</Tooltip.Body>
</Tooltip>
) : undefined}
</div>
</th>
{/* We only want to show the version column if there are agents */}
{loaderData.agent !== undefined ? (
<th className="uppercase text-xs font-bold pb-2">Version</th>
) : undefined}
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
<div className="mb-4 flex items-center gap-4">
<div className="relative w-64">
<Input
label="Search machines"
labelHidden
maxLength={100}
onChange={(value) => setSearchQuery(value.slice(0, 100))}
placeholder="Search by name or IP address..."
value={searchQuery}
/>
{searchQuery && (
<button
aria-label="Clear search"
className={cn(
'absolute right-2 top-1/2 -translate-y-1/2',
'p-1 rounded-full',
'text-headplane-400 hover:text-headplane-600',
'dark:text-headplane-500 dark:hover:text-headplane-300',
'hover:bg-headplane-100 dark:hover:bg-headplane-800',
)}
onClick={() => setSearchQuery('')}
type="button"
>
<X className="w-4 h-4" />
</button>
)}
>
{loaderData.populatedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
isAgent={
loaderData.agent ? loaderData.agent === node.nodeKey : undefined
}
isDisabled={
loaderData.writable
? false // If the user has write permissions, they can edit all machines
: node.user.providerId?.split('/').pop() !==
loaderData.subject
}
key={node.id}
magic={loaderData.magic}
node={node}
users={loaderData.users}
/>
))}
</tbody>
</table>
</div>
<span className="text-sm text-headplane-500 whitespace-nowrap">
{searchQuery
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
: `${loaderData.populatedNodes.length} machines`}
</span>
</div>
<div className="overflow-x-auto">
<table className="table-auto w-full rounded-lg min-w-[640px]">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th
aria-sort={
sortField === 'name'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by name"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('name')}
type="button"
>
Name
{sortField === 'name' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
<th
aria-sort={
sortField === 'ip'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="pb-2 w-1/4"
>
<div className="flex items-center gap-x-1">
<button
aria-label="Sort by IP address"
className={cn(
'flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('ip')}
type="button"
>
Addresses
{sortField === 'ip' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
{loaderData.magic ? (
<Tooltip>
<Info className="w-4 h-4" />
<Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based
on their name and also at{' '}
<Code>
[name].
{loaderData.magic}
</Code>
</Tooltip.Body>
</Tooltip>
) : undefined}
</div>
</th>
{/* We only want to show the version column if there are agents */}
{loaderData.agent !== undefined ? (
<th
aria-sort={
sortField === 'version'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by version"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('version')}
type="button"
>
Version
{sortField === 'version' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
) : undefined}
<th
aria-sort={
sortField === 'lastSeen'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by last seen"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('lastSeen')}
type="button"
>
Last Seen
{sortField === 'lastSeen' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{filteredAndSortedNodes.length === 0 ? (
<tr>
<td
className="py-8 text-center text-headplane-500"
colSpan={loaderData.agent !== undefined ? 5 : 4}
>
No machines found matching "{searchQuery}"
</td>
</tr>
) : (
filteredAndSortedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
isAgent={
loaderData.agent
? loaderData.agent === node.nodeKey
: undefined
}
isDisabled={
loaderData.writable
? false // If the user has write permissions, they can edit all machines
: node.user.providerId?.split('/').pop() !==
loaderData.subject
}
key={node.id}
magic={loaderData.magic}
node={node}
users={loaderData.users}
supportsNodeOwnerChange={loaderData.supportsNodeOwnerChange}
/>
))
)}
</tbody>
</table>
</div>
</>
);
}
+81 -82
View File
@@ -1,95 +1,94 @@
import { data } from 'react-router';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
import { data } from "react-router";
import { Capabilities } from "~/server/web/roles";
import type { Route } from "./+types/overview";
export async function authKeysAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.generate_authkeys,
);
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.generate_authkeys);
if (!check) {
throw data('You do not have permission to manage pre-auth keys', {
status: 403,
});
}
if (!check) {
throw data("You do not have permission to manage pre-auth keys", {
status: 403,
});
}
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(session.api_key);
const action = formData.get('action_id')?.toString();
if (!action) {
throw data('Missing `action_id` in the form data.', {
status: 400,
});
}
const formData = await request.formData();
const api = context.hsApi.getRuntimeClient(session.api_key);
const action = formData.get("action_id")?.toString();
if (!action) {
throw data("Missing `action_id` in the form data.", {
status: 400,
});
}
switch (action) {
case 'add_preauthkey': {
const user = formData.get('user_id')?.toString();
if (!user) {
return data('Missing `user_id` in the form data.', {
status: 400,
});
}
switch (action) {
case "add_preauthkey": {
const user = formData.get("user_id")?.toString();
if (!user) {
return data("Missing `user_id` in the form data.", {
status: 400,
});
}
const expiry = formData.get('expiry')?.toString();
if (!expiry) {
return data('Missing `expiry` 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 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,
});
}
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
);
// Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(" ")[0]);
const date = new Date();
date.setDate(date.getDate() + day);
const key = await api.createPreAuthKey(
user,
ephemeral === "on",
reusable === "on",
date,
[], // TODO
);
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,
});
}
return data({ success: true as const, key: key.key });
}
case "expire_preauthkey": {
const key = formData.get("key")?.toString();
if (!key) {
return data("Missing `key` in the form data.", {
status: 400,
});
}
const user = formData.get('user_id')?.toString();
if (!user) {
return data('Missing `user_id` in the form data.', {
status: 400,
});
}
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,
});
}
await api.expirePreAuthKey(user, key);
return data("Pre-auth key expired");
}
default:
return data("Invalid action", {
status: 400,
});
}
}
+26 -52
View File
@@ -1,58 +1,32 @@
import Attribute from '~/components/Attribute';
import Button from '~/components/Button';
import Code from '~/components/Code';
import type { PreAuthKey, User } from '~/types';
import toast from '~/utils/toast';
import ExpireAuthKey from './dialogs/expire-auth-key';
import type { PreAuthKey, User } from "~/types";
import Attribute from "~/components/Attribute";
import ExpireAuthKey from "./dialogs/expire-auth-key";
interface Props {
authKey: PreAuthKey;
user: User;
url: string;
authKey: PreAuthKey;
user: User;
}
export default function AuthKeyRow({ authKey, user, url }: Props) {
const createdAt = new Date(authKey.createdAt).toLocaleString();
const expiration = new Date(authKey.expiration).toLocaleString();
export default function AuthKeyRow({ authKey, user }: Props) {
const createdAt = new Date(authKey.createdAt).toLocaleString();
const expiration = new Date(authKey.expiration).toLocaleString();
return (
<div className="w-full">
<Attribute isCopyable name="Key" value={authKey.key} />
<Attribute
isCopyable
name="User"
value={user.name || user.displayName || user.email || user.id}
/>
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
<Attribute name="Created" value={createdAt} />
<Attribute name="Expiration" value={expiration} />
<p className="mb-1 mt-4">
To use this key, run the following command on your device:
</p>
<Code className="text-sm">
tailscale up --login-server={url} --authkey {authKey.key}
</Code>
<div className="flex gap-4 items-center" suppressHydrationWarning>
{(authKey.used && !authKey.reusable) ||
new Date(authKey.expiration) < new Date() ? undefined : (
<ExpireAuthKey authKey={authKey} user={user} />
)}
<Button
className="my-4"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server=${url} --authkey ${authKey.key}`,
);
toast('Copied command to clipboard');
}}
variant="light"
>
Copy Tailscale Command
</Button>
</div>
</div>
);
return (
<div className="w-full">
<Attribute name="Key" value={authKey.key} />
<Attribute name="User" value={user.name || user.displayName || user.email || user.id} />
<Attribute name="Reusable" value={authKey.reusable ? "Yes" : "No"} />
<Attribute name="Ephemeral" value={authKey.ephemeral ? "Yes" : "No"} />
<Attribute name="Used" value={authKey.used ? "Yes" : "No"} />
<Attribute name="Created" value={createdAt} />
<Attribute name="Expiration" value={expiration} />
{!((authKey.used && !authKey.reusable) || new Date(authKey.expiration) < new Date()) && (
<div className="mt-2" suppressHydrationWarning>
<ExpireAuthKey authKey={authKey} user={user} />
</div>
)}
</div>
);
}
@@ -1,101 +1,166 @@
import { Key, useState } from 'react';
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import NumberInput from '~/components/NumberInput';
import Select from '~/components/Select';
import Switch from '~/components/Switch';
import type { User } from '~/types';
import { Key, useEffect, useRef, useState } from "react";
import { useFetcher } from "react-router";
import type { User } from "~/types";
import Button from "~/components/Button";
import Code from "~/components/Code";
import Dialog from "~/components/Dialog";
import Link from "~/components/Link";
import NumberInput from "~/components/NumberInput";
import Select from "~/components/Select";
import Switch from "~/components/Switch";
import toast from "~/utils/toast";
interface AddAuthKeyProps {
users: User[];
users: User[];
url: string;
}
// TODO: Tags
export default function AddAuthKey(data: AddAuthKeyProps) {
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [userId, setUserId] = useState<Key | null>(data.users[0]?.id);
export default function AddAuthKey({ users, url }: AddAuthKeyProps) {
const fetcher = useFetcher();
const submittingRef = useRef(false);
const [isOpen, setIsOpen] = useState(false);
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [userId, setUserId] = useState<Key | null>(users[0]?.id);
return (
<Dialog>
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Generate auth key</Dialog.Title>
<input name="action_id" type="hidden" value="add_preauthkey" />
<input name="user_id" type="hidden" value={userId?.toString()} />
<Select
className="mb-2"
description="This is the user machines will belong to when they authenticate."
isRequired
label="User"
name="user"
onSelectionChange={(value) => {
setUserId(value);
}}
placeholder="Select a user"
>
{data.users.map((user) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
<NumberInput
defaultValue={90}
description="Set this key to expire after a certain number of days."
formatOptions={{
style: 'unit',
unit: 'day',
unitDisplay: 'short',
}}
isRequired
label="Key Expiration"
maxValue={365_000} // 1000 years
minValue={1}
name="expiry"
/>
<div className="flex justify-between items-center gap-2 mt-6">
<div>
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
<Dialog.Text className="text-sm">
Use this key to authenticate more than one device.
</Dialog.Text>
</div>
<Switch
defaultSelected={reusable}
label="Reusable"
name="reusable"
onChange={() => {
setReusable(!reusable);
}}
/>
</div>
<input name="reusable" type="hidden" value={reusable.toString()} />
<div className="flex justify-between items-center gap-2 mt-6">
<div>
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
<Dialog.Text className="text-sm">
Devices authenticated with this key will be automatically removed
once they go offline.{' '}
<Link
name="Tailscale Ephemeral Nodes Documentation"
to="https://tailscale.com/kb/1111/ephemeral-nodes"
>
Learn more
</Link>
</Dialog.Text>
</div>
<Switch
defaultSelected={ephemeral}
label="Ephemeral"
name="ephemeral"
onChange={() => {
setEphemeral(!ephemeral);
}}
/>
</div>
<input name="ephemeral" type="hidden" value={ephemeral.toString()} />
</Dialog.Panel>
</Dialog>
);
const createdKey = fetcher.data?.success ? fetcher.data.key : null;
useEffect(() => {
if (fetcher.state === "idle" && fetcher.data) {
submittingRef.current = false;
}
}, [fetcher.data, fetcher.state]);
useEffect(() => {
if (!isOpen) {
setReusable(false);
setEphemeral(false);
setUserId(users[0]?.id);
fetcher.data = undefined;
}
}, [isOpen]);
return (
<Dialog
isOpen={isOpen}
onOpenChange={(open) => {
if (!open && submittingRef.current) return;
setIsOpen(open);
}}
>
<Button className="my-4" onPress={() => setIsOpen(true)}>
Create pre-auth key
</Button>
{createdKey ? (
<Dialog.Panel variant="unactionable">
<Dialog.Title>Pre-auth key created</Dialog.Title>
<Dialog.Text>
Copy this key now. You will not be able to see the full key again.
</Dialog.Text>
<div className="bg-headplane-100 dark:bg-headplane-800 mt-4 flex items-center gap-2 rounded-lg px-3 py-2">
<code className="min-w-0 flex-1 truncate font-mono text-sm">{createdKey}</code>
<Button
className="shrink-0"
onPress={async () => {
await navigator.clipboard.writeText(createdKey);
toast("Copied key to clipboard");
}}
variant="light"
>
Copy
</Button>
</div>
<Dialog.Text className="mt-4 text-sm">To register a device with this key:</Dialog.Text>
<Code isCopyable className="mt-1 block text-sm">
{`tailscale up --login-server=${url} --authkey ${createdKey}`}
</Code>
</Dialog.Panel>
) : (
<Dialog.Panel
onSubmit={(event) => {
event.preventDefault();
submittingRef.current = true;
const form = new FormData(event.currentTarget as HTMLFormElement);
form.set("action_id", "add_preauthkey");
form.set("user_id", userId?.toString() ?? "");
form.set("reusable", reusable ? "on" : "off");
form.set("ephemeral", ephemeral ? "on" : "off");
fetcher.submit(form, { method: "POST" });
}}
isDisabled={fetcher.state !== "idle"}
>
<Dialog.Title>Generate auth key</Dialog.Title>
<Select
className="mb-2"
description="This is the user machines will belong to when they authenticate."
isRequired
label="User"
onSelectionChange={(value) => {
setUserId(value);
}}
placeholder="Select a user"
>
{users.map((user) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
<NumberInput
defaultValue={90}
description="Set this key to expire after a certain number of days."
formatOptions={{
style: "unit",
unit: "day",
unitDisplay: "short",
}}
isRequired
label="Key Expiration"
maxValue={365_000}
minValue={1}
name="expiry"
/>
<div className="mt-6 flex items-center justify-between gap-2">
<div>
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
<Dialog.Text className="text-sm">
Use this key to authenticate more than one device.
</Dialog.Text>
</div>
<Switch
defaultSelected={reusable}
label="Reusable"
onChange={() => {
setReusable(!reusable);
}}
/>
</div>
<div className="mt-6 flex items-center justify-between gap-2">
<div>
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
<Dialog.Text className="text-sm">
Devices authenticated with this key will be automatically removed once they go
offline.{" "}
<Link
name="Tailscale Ephemeral Nodes Documentation"
to="https://tailscale.com/kb/1111/ephemeral-nodes"
>
Learn more
</Link>
</Dialog.Text>
</div>
<Switch
defaultSelected={ephemeral}
label="Ephemeral"
onChange={() => {
setEphemeral(!ephemeral);
}}
/>
</div>
</Dialog.Panel>
)}
</Dialog>
);
}
+207 -220
View File
@@ -1,245 +1,232 @@
import { FileKey2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import { Link as RemixLink } from 'react-router';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Select from '~/components/Select';
import TableList from '~/components/TableList';
import { Capabilities } from '~/server/web/roles';
import log from '~/utils/log';
import type { Route } from './+types/overview';
import { authKeysAction } from './actions';
import AuthKeyRow from './auth-key-row';
import AddAuthKey from './dialogs/add-auth-key';
import { FileKey2 } from "lucide-react";
import { useMemo, useState } from "react";
import { Link as RemixLink } from "react-router";
import Code from "~/components/Code";
import Link from "~/components/Link";
import Notice from "~/components/Notice";
import Select from "~/components/Select";
import TableList from "~/components/TableList";
import { Capabilities } from "~/server/web/roles";
import 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 }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const api = context.hsApi.getRuntimeClient(session.api_key);
const session = await context.sessions.auth(request);
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) => {
try {
const preAuthKeys = await api.getPreAuthKeys(user.id);
return {
success: true,
user,
preAuthKeys,
};
} catch (error) {
log.error('api', 'GET /v1/preauthkey for %s: %o', user.name, error);
return {
success: false,
user,
error,
preAuthKeys: [],
};
}
}),
);
const users = await api.getUsers();
const preAuthKeys = await Promise.all(
users
.filter((user) => user.name?.length > 0) // Filter out any invalid users
.map(async (user) => {
try {
const preAuthKeys = await api.getPreAuthKeys(user.id);
return {
success: true,
user,
preAuthKeys,
};
} catch (error) {
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
return {
success: false,
user,
error,
preAuthKeys: [],
};
}
}),
);
const keys = preAuthKeys
.filter(({ success }) => success)
.map(({ user, preAuthKeys }) => ({
user,
preAuthKeys,
}));
const keys = preAuthKeys
.filter(({ success }) => success)
.map(({ user, preAuthKeys }) => ({
user,
preAuthKeys,
}));
const missing = preAuthKeys
.filter(({ success }) => !success)
.map(({ user, error }) => ({
user,
error,
}));
const missing = preAuthKeys
.filter(({ success }) => !success)
.map(({ user, error }) => ({
user,
error,
}));
return {
keys,
missing,
users,
access: await context.sessions.check(
request,
Capabilities.generate_authkeys,
),
url: context.config.headscale.public_url ?? context.config.headscale.url,
};
return {
keys,
missing,
users,
access: await context.sessions.check(request, Capabilities.generate_authkeys),
url: context.config.headscale.public_url ?? context.config.headscale.url,
};
}
export const action = authKeysAction;
type Status = 'all' | 'active' | 'expired' | 'reusable' | 'ephemeral';
type Status = "all" | "active" | "expired" | "reusable" | "ephemeral";
export default function Page({
loaderData: { keys, missing, users, url, access },
loaderData: { keys, missing, users, url, access },
}: Route.ComponentProps) {
const [selectedUser, setSelectedUser] = useState('__headplane_all');
const [status, setStatus] = useState<Status>('active');
const isDisabled =
!access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0;
const [selectedUser, setSelectedUser] = useState("__headplane_all");
const [status, setStatus] = useState<Status>("active");
const isDisabled = !access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0;
const filteredKeys = useMemo(() => {
const now = new Date();
return keys
.filter(({ user }) => {
if (selectedUser === '__headplane_all') {
return true;
}
const filteredKeys = useMemo(() => {
const now = new Date();
return keys
.filter(({ user }) => {
if (selectedUser === "__headplane_all") {
return true;
}
return user.id === selectedUser;
})
.flatMap(({ preAuthKeys }) => preAuthKeys)
.filter((key) => {
if (status === 'all') {
return true;
}
return user.id === selectedUser;
})
.flatMap(({ preAuthKeys }) => preAuthKeys)
.filter((key) => {
if (status === "all") {
return true;
}
if (status === 'ephemeral') {
return key.ephemeral;
}
if (status === "ephemeral") {
return key.ephemeral;
}
if (status === 'reusable') {
return key.reusable;
}
if (status === "reusable") {
return key.reusable;
}
const expiry = new Date(key.expiration);
if (status === 'expired') {
// Expired keys are either used or expired
// BUT only used if they are not reusable
if (key.used && !key.reusable) {
return true;
}
const expiry = new Date(key.expiration);
if (status === "expired") {
// Expired keys are either used or expired
// BUT only used if they are not reusable
if (key.used && !key.reusable) {
return true;
}
return expiry < now;
}
return expiry < now;
}
if (status === 'active') {
// Active keys are either not expired or reusable
if (expiry < now) {
return false;
}
if (status === "active") {
// Active keys are either not expired or reusable
if (expiry < now) {
return false;
}
if (!key.used) {
return true;
}
if (!key.used) {
return true;
}
return key.reusable;
}
return key.reusable;
}
return false;
});
}, [keys, selectedUser, status]);
return false;
});
}, [keys, selectedUser, status]);
return (
<div className="flex flex-col md:w-2/3">
<p className="mb-8 text-md">
<RemixLink className="font-medium" to="/settings">
Settings
</RemixLink>
<span className="mx-2">/</span> Pre-Auth Keys
</p>
{!access ? (
<Notice title="Pre-auth key permissions restricted" variant="warning">
You do not have the necessary permissions to generate pre-auth keys.
Please contact your administrator to request access or to generate a
pre-auth key for you.
</Notice>
) : missing.length > 0 ? (
<Notice title="Missing authentication keys" variant="error">
An error occurred while fetching the authentication keys for the
following users:{' '}
{missing.map(({ user }, index) => (
<>
<Code key={user.name}>{user.name}</Code>
{index < missing.length - 1 ? ', ' : '. '}
</>
))}
Their keys may not be listed correctly. Please check the server logs
for more information.
</Notice>
) : undefined}
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1>
<p className="mb-4">
Headscale fully supports pre-authentication keys in order to easily add
devices to your Tailnet. To learn more about using pre-authentication
keys, visit the{' '}
<Link
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
Tailscale documentation
</Link>
</p>
<AddAuthKey users={users} />
<div className="flex items-center gap-4 mt-4">
<Select
className="w-full"
defaultSelectedKey="__headplane_all"
isDisabled={isDisabled}
label="User"
onSelectionChange={(value) =>
setSelectedUser(value?.toString() ?? '')
}
placeholder="Select a user"
>
{[
<Select.Item key="__headplane_all">All</Select.Item>,
...keys.map(({ user }) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
)),
]}
</Select>
<Select
className="w-full"
defaultSelectedKey="active"
isDisabled={isDisabled}
label="Status"
onSelectionChange={(value) =>
setStatus((value?.toString() ?? 'active') as Status)
}
placeholder="Select a status"
>
<Select.Item key="all">All</Select.Item>
<Select.Item key="active">Active</Select.Item>
<Select.Item key="expired">Used/Expired</Select.Item>
<Select.Item key="reusable">Reusable</Select.Item>
<Select.Item key="ephemeral">Ephemeral</Select.Item>
</Select>
</div>
<TableList className="mt-4">
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<FileKey2 />
<p className="font-semibold">
No pre-auth keys have been created yet.
</p>
</TableList.Item>
) : filteredKeys.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<FileKey2 />
<p className="font-semibold">
No pre-auth keys match the selected filters.
</p>
</TableList.Item>
) : (
filteredKeys.map((key) => {
// TODO: Why is Headscale using email as the user ID here?
// https://github.com/juanfont/headscale/issues/2520
const user = users.find((user) => user.id === key.user.id);
if (!user) {
return null;
}
return (
<div className="flex flex-col md:w-2/3">
<p className="text-md mb-8">
<RemixLink className="font-medium" to="/settings">
Settings
</RemixLink>
<span className="mx-2">/</span> Pre-Auth Keys
</p>
{!access ? (
<Notice title="Pre-auth key permissions restricted" variant="warning">
You do not have the necessary permissions to generate pre-auth keys. Please contact your
administrator to request access or to generate a pre-auth key for you.
</Notice>
) : missing.length > 0 ? (
<Notice title="Missing authentication keys" variant="error">
An error occurred while fetching the authentication keys for the following users:{" "}
{missing.map(({ user }, index) => (
<>
<Code key={user.name}>{user.name}</Code>
{index < missing.length - 1 ? ", " : ". "}
</>
))}
Their keys may not be listed correctly. Please check the server logs for more information.
</Notice>
) : undefined}
<h1 className="mb-2 text-2xl font-medium">Pre-Auth Keys</h1>
<p className="mb-4">
Headscale fully supports pre-authentication keys in order to easily add devices to your
Tailnet. To learn more about using pre-authentication keys, visit the{" "}
<Link
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
Tailscale documentation
</Link>
</p>
<AddAuthKey url={url} users={users} />
<div className="mt-4 flex items-center gap-4">
<Select
className="w-full"
defaultSelectedKey="__headplane_all"
isDisabled={isDisabled}
label="User"
onSelectionChange={(value) => setSelectedUser(value?.toString() ?? "")}
placeholder="Select a user"
>
{[
<Select.Item key="__headplane_all">All</Select.Item>,
...keys.map(({ user }) => (
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
)),
]}
</Select>
<Select
className="w-full"
defaultSelectedKey="active"
isDisabled={isDisabled}
label="Status"
onSelectionChange={(value) => setStatus((value?.toString() ?? "active") as Status)}
placeholder="Select a status"
>
<Select.Item key="all">All</Select.Item>
<Select.Item key="active">Active</Select.Item>
<Select.Item key="expired">Used/Expired</Select.Item>
<Select.Item key="reusable">Reusable</Select.Item>
<Select.Item key="ephemeral">Ephemeral</Select.Item>
</Select>
</div>
<TableList className="mt-4">
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<FileKey2 />
<p className="font-semibold">No pre-auth keys have been created yet.</p>
</TableList.Item>
) : filteredKeys.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<FileKey2 />
<p className="font-semibold">No pre-auth keys match the selected filters.</p>
</TableList.Item>
) : (
filteredKeys.map((key) => {
// TODO: Why is Headscale using email as the user ID here?
// https://github.com/juanfont/headscale/issues/2520
const user = users.find((user) => user.id === key.user.id);
if (!user) {
return null;
}
return (
<TableList.Item key={key.id}>
<AuthKeyRow authKey={key} url={url} user={user} />
</TableList.Item>
);
})
)}
</TableList>
</div>
);
return (
<TableList.Item key={key.id}>
<AuthKeyRow authKey={key} user={user} />
</TableList.Item>
);
})
)}
</TableList>
</div>
);
}
+68 -71
View File
@@ -1,76 +1,73 @@
import { ArrowRight } from 'lucide-react';
import { Link as RemixLink } from 'react-router';
import Link from '~/components/Link';
import type { Route } from './+types/overview';
import { ArrowRight } from "lucide-react";
import { Link as RemixLink } from "react-router";
import Link from "~/components/Link";
import type { Route } from "./+types/overview";
export async function loader({ context }: Route.LoaderArgs) {
return {
config: context.hs.writable(),
isOidcEnabled: context.oidcConnector?.isValid ?? false,
};
const oidcConnector = await context.oidcConnector?.get();
return {
config: context.hs.writable(),
isOidcEnabled: oidcConnector?.isValid ?? false,
};
}
export default function Page({
loaderData: { config, isOidcEnabled },
}: Route.ComponentProps) {
return (
<div className="flex flex-col gap-8 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Settings</h1>
<p>
The settings page is still under construction. As I'm able to add more
features, I'll be adding them here. If you require any features, feel
free to open an issue on the GitHub repository.
</p>
</div>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
<p>
Headscale fully supports pre-authentication keys in order to easily
add devices to your Tailnet. To learn more about using
pre-authentication keys, visit the{' '}
<Link
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
Tailscale documentation
</Link>
</p>
</div>
<RemixLink to="/settings/auth-keys">
<div className="text-lg font-medium flex items-center">
Manage Auth Keys
<ArrowRight className="w-5 h-5 ml-2" />
</div>
</RemixLink>
{config && isOidcEnabled ? (
<>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">
Authentication Restrictions
</h1>
<p>
Headscale supports restricting OIDC authentication to only allow
certain email domains, groups, or users to authenticate. This can
be used to limit access to your Tailnet to only certain users or
groups and Headplane will also respect these settings when
authenticating.{' '}
<Link
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
Learn More
</Link>
</p>
</div>
<RemixLink to="/settings/restrictions">
<div className="text-lg font-medium flex items-center">
Manage Restrictions
<ArrowRight className="w-5 h-5 ml-2" />
</div>
</RemixLink>
</>
) : undefined}
</div>
);
export default function Page({ loaderData: { config, isOidcEnabled } }: Route.ComponentProps) {
return (
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">Settings</h1>
<p>
The settings page is still under construction. As I'm able to add more features, I'll be
adding them here. If you require any features, feel free to open an issue on the GitHub
repository.
</p>
</div>
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">Pre-Auth Keys</h1>
<p>
Headscale fully supports pre-authentication keys in order to easily add devices to your
Tailnet. To learn more about using pre-authentication keys, visit the{" "}
<Link
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
Tailscale documentation
</Link>
</p>
</div>
<RemixLink to="/settings/auth-keys">
<div className="flex items-center text-lg font-medium">
Manage Auth Keys
<ArrowRight className="ml-2 h-5 w-5" />
</div>
</RemixLink>
{config && isOidcEnabled ? (
<>
<div className="flex w-full flex-col sm:w-2/3">
<h1 className="mb-4 text-2xl font-medium">Authentication Restrictions</h1>
<p>
Headscale supports restricting OIDC authentication to only allow certain email
domains, groups, or users to authenticate. This can be used to limit access to your
Tailnet to only certain users or groups and Headplane will also respect these settings
when authenticating.{" "}
<Link
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
Learn More
</Link>
</p>
</div>
<RemixLink to="/settings/restrictions">
<div className="flex items-center text-lg font-medium">
Manage Restrictions
<ArrowRight className="ml-2 h-5 w-5" />
</div>
</RemixLink>
</>
) : undefined}
</div>
);
}
@@ -43,7 +43,7 @@ export default function Page({
return (
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-2/3">
<div className="flex flex-col w-full sm:w-2/3">
<p className="mb-4 text-md">
<RemixLink className="font-medium" to="/settings">
Settings
+2 -2
View File
@@ -19,7 +19,7 @@ export default function RestrictionTable({
isDisabled,
}: RestrictionProps) {
return (
<div className="w-2/3">
<div className="w-full sm:w-2/3">
<h2 className="text-2xl font-medium mt-8">
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
</h2>
@@ -61,7 +61,7 @@ export default function RestrictionTable({
) : (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
{iconForType(type)}
<p className="font-semibold">
<p className="font-semibold text-center">
All {type}s are permitted to authenticate.
</p>
</TableList.Item>
+225 -242
View File
@@ -1,293 +1,276 @@
/** biome-ignore-all lint/correctness/noNestedComponentDefinitions: Wtf? */
import { faker } from "@faker-js/faker";
import { eq } from "drizzle-orm";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { data, type ShouldRevalidateFunction, useSubmit } from "react-router";
import { ExternalScriptsHandle } from "remix-utils/external-scripts";
import { faker } from '@faker-js/faker';
import { eq } from 'drizzle-orm';
import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import { data, type ShouldRevalidateFunction, useSubmit } from 'react-router';
import { ExternalScriptsHandle } from 'remix-utils/external-scripts';
import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
import { useLiveData } from '~/utils/live-data';
import type { Route } from './+types/console';
import UserPrompt from './user-prompt';
import XTerm from './xterm.client';
import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema";
import { useLiveData } from "~/utils/live-data";
import type { Route } from "./+types/console";
import UserPrompt from "./user-prompt";
import XTerm from "./xterm.client";
export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
return false;
};
export async function loader({ request, context }: Route.LoaderArgs) {
const origin = new URL(request.url).origin;
const assets = ['/wasm_exec.js', '/hp_ssh.wasm'];
const missing: string[] = [];
const origin = new URL(request.url).origin;
const assets = ["/wasm_exec.js", "/hp_ssh.wasm"];
const missing: string[] = [];
for (const file of assets) {
const res = await fetch(`${origin}${file}`, { method: 'HEAD' });
if (!res.ok) missing.push(file);
}
for (const file of assets) {
const res = await fetch(`${origin}${file}`, { method: "HEAD" });
if (!res.ok) missing.push(file);
}
if (missing.length > 0) {
throw data('WebSSH is not configured in this build.', 405);
}
if (missing.length > 0) {
throw data("WebSSH is not configured in this build.", 405);
}
if (!context.agents?.agentID()) {
throw data(
'WebSSH is only available with the Headplane agent integration',
400,
);
}
if (!context.agents?.agentID()) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
const session = await context.sessions.auth(request);
if (session.user.subject === 'unknown-non-oauth') {
throw data('Only OAuth users are allowed to use WebSSH', 403);
}
const session = await context.sessions.auth(request);
if (session.user.subject === "unknown-non-oauth") {
throw data("Only OAuth users are allowed to use WebSSH", 403);
}
const api = context.hsApi.getRuntimeClient(session.api_key);
const users = await api.getUsers();
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
// pre-authkey which REQUIRES a user ID, meaning the user has to have
// authenticated with Headscale first.
const lookup = users.find((u) => {
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
return subject === session.user.subject;
});
// MARK: This assumes that a user has authenticated with Headscale first
// Since the only way to enforce permissions via ACLs is to generate a
// pre-authkey which REQUIRES a user ID, meaning the user has to have
// authenticated with Headscale first.
const lookup = users.find((u) => {
const subject = u.providerId?.split("/").pop();
if (!subject) {
return false;
}
return subject === session.user.subject;
});
if (!lookup) {
throw data(
`User with subject ${session.user.subject} not found within Headscale`,
404,
);
}
if (!lookup) {
throw data(`User with subject ${session.user.subject} not found within Headscale`, 404);
}
const preAuthKey = await api.createPreAuthKey(
lookup.id,
true, // ephemeral
false, // reusable
new Date(Date.now() + 60 * 1000), // expiration: 1 minute
null, // aclTags
);
const preAuthKey = await api.createPreAuthKey(
lookup.id,
true, // ephemeral
false, // reusable
new Date(Date.now() + 60 * 1000), // expiration: 1 minute
null, // aclTags
);
// TODO: Enable config to enforce generate_authkeys capability
// For now, any user is capable of WebSSH connections
// const check = await context.sessions.check(
// request,
// Capabilities.generate_authkeys,
// );
// TODO: Enable config to enforce generate_authkeys capability
// For now, any user is capable of WebSSH connections
// const check = await context.sessions.check(
// request,
// Capabilities.generate_authkeys,
// );
const qp = new URL(request.url).searchParams;
const username = qp.get('username') || undefined;
const hostname = qp.get('hostname') || undefined;
if (!hostname) {
throw data('Missing required parameter: hostname', 400);
}
const qp = new URL(request.url).searchParams;
const username = qp.get("username") || undefined;
const hostname = qp.get("hostname") || undefined;
if (!hostname) {
throw data("Missing required parameter: hostname", 400);
}
if (!username) {
return {
ipnDetails: undefined,
sshDetails: {
username,
hostname,
},
};
}
if (!username) {
return {
ipnDetails: undefined,
sshDetails: {
username,
hostname,
},
};
}
// We're making a request to <url>/key?v=116 to check the CORS headers
const u = context.config.headscale.public_url ?? context.config.headscale.url;
// const res = await fetch(`${u}/key?v=116`, {
// method: 'GET',
// });
// We're making a request to <url>/key?v=116 to check the CORS headers
const u = context.config.headscale.public_url ?? context.config.headscale.url;
// const res = await fetch(`${u}/key?v=116`, {
// method: 'GET',
// });
// const corsOrigin = res.headers.get('Access-Control-Allow-Origin');
// const corsMethods = res.headers.get('Access-Control-Allow-Methods');
// const corsHeaders = res.headers.get('Access-Control-Allow-Headers');
// console.log(corsOrigin, corsMethods, corsHeaders);
// const corsOrigin = res.headers.get('Access-Control-Allow-Origin');
// const corsMethods = res.headers.get('Access-Control-Allow-Methods');
// const corsHeaders = res.headers.get('Access-Control-Allow-Headers');
// console.log(corsOrigin, corsMethods, corsHeaders);
// if (!corsOrigin || !corsMethods || !corsHeaders) {
// throw data(
// 'Headscale server does not have the required CORS headers for WebSSH',
// 500,
// );
// }
// if (!corsOrigin || !corsMethods || !corsHeaders) {
// throw data(
// 'Headscale server does not have the required CORS headers for WebSSH',
// 500,
// );
// }
const nodes = await api.getNodes();
const lookupNode = nodes.find((n) => n.givenName === hostname);
if (!lookupNode) {
throw data(`Node with hostname ${hostname} not found`, 404);
}
const nodes = await api.getNodes();
const lookupNode = nodes.find((n) => n.givenName === hostname);
if (!lookupNode) {
throw data(`Node with hostname ${hostname} not found`, 404);
}
// Last thing is keeping track of the ephemeral node in the database
// because Headscale doesn't automatically delete ephemeral nodes???
const [_ephemeralNode] = await context.db
.insert(ephemeralNodes)
.values({
auth_key: preAuthKey.key,
} satisfies EphemeralNodeInsert)
.returning();
// Last thing is keeping track of the ephemeral node in the database
// because Headscale doesn't automatically delete ephemeral nodes???
const [_ephemeralNode] = await context.db
.insert(ephemeralNodes)
.values({
auth_key: preAuthKey.key,
} satisfies EphemeralNodeInsert)
.returning();
return {
ipnDetails: {
PreAuthKey: preAuthKey.key,
Hostname: generateHostname(username),
ControlURL: u,
},
return {
ipnDetails: {
PreAuthKey: preAuthKey.key,
Hostname: generateHostname(username),
ControlURL: u,
},
sshDetails: {
username,
hostname,
},
};
sshDetails: {
username,
hostname,
},
};
}
function generateHostname(username: string) {
const adjective = faker.word.adjective({
length: {
min: 3,
max: 6,
},
});
const adjective = faker.word.adjective({
length: {
min: 3,
max: 6,
},
});
const noun = faker.word.noun({
length: {
min: 3,
max: 6,
},
});
const noun = faker.word.noun({
length: {
min: 3,
max: 6,
},
});
return `ssh-${adjective}-${noun}-${username}`;
return `ssh-${adjective}-${noun}-${username}`;
}
export async function action({ request, context }: Route.ActionArgs) {
await context.sessions.auth(request);
if (!context.agents?.agentID()) {
throw data(
'WebSSH is only available with the Headplane agent integration',
400,
);
}
await context.sessions.auth(request);
if (!context.agents?.agentID()) {
throw data("WebSSH is only available with the Headplane agent integration", 400);
}
const form = await request.formData();
const nodeKey = form.get('node_key');
const authKey = form.get('auth_key');
const form = await request.formData();
const nodeKey = form.get("node_key");
const authKey = form.get("auth_key");
if (nodeKey === null || typeof nodeKey !== 'string') {
throw data('Missing node_key', 400);
}
if (nodeKey === null || typeof nodeKey !== "string") {
throw data("Missing node_key", 400);
}
if (authKey === null || typeof authKey !== 'string') {
throw data('Missing auth_key', 400);
}
if (authKey === null || typeof authKey !== "string") {
throw data("Missing auth_key", 400);
}
await context.db
.update(ephemeralNodes)
.set({
node_key: nodeKey,
})
.where(eq(ephemeralNodes.auth_key, authKey));
await context.db
.update(ephemeralNodes)
.set({
node_key: nodeKey,
})
.where(eq(ephemeralNodes.auth_key, authKey));
}
export const links: Route.LinksFunction = () => [
{
rel: 'preload',
href: '/hp_ssh.wasm',
as: 'fetch',
type: 'application/wasm',
crossOrigin: 'anonymous',
},
{
rel: "preload",
href: "/hp_ssh.wasm",
as: "fetch",
type: "application/wasm",
crossOrigin: "anonymous",
},
];
export const handle: ExternalScriptsHandle = {
scripts: [
{
src: '/wasm_exec.js',
crossOrigin: 'anonymous',
preload: true,
},
],
scripts: [
{
src: "/wasm_exec.js",
crossOrigin: "anonymous",
preload: true,
},
],
};
export default function Page({
loaderData: { ipnDetails, sshDetails },
}: Route.ComponentProps) {
const submit = useSubmit();
const { pause } = useLiveData();
export default function Page({ loaderData: { ipnDetails, sshDetails } }: Route.ComponentProps) {
const submit = useSubmit();
const { pause } = useLiveData();
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
const [nodeKey, setNodeKey] = useState<string | null>(null);
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
const [nodeKey, setNodeKey] = useState<string | null>(null);
useEffect(() => {
if (!ipnDetails) {
return;
}
useEffect(() => {
if (!ipnDetails) {
return;
}
pause();
const go = new Go(); // Go is defined by wasm_exec.js
WebAssembly.instantiateStreaming(
fetch('/hp_ssh.wasm'),
go.importObject,
).then((value) => {
go.run(value.instance);
const handle = TsWasmNet(ipnDetails, {
NotifyState: (state) => {
console.log('State changed:', state);
if (state === 'Running') {
setIpn(handle);
}
},
NotifyNetMap: (netmap) => {
// Only set NodeKey if it is not already set and then
// also dispatch that to the backend to track the
// ephemeral node.
//
// We open an SSE connection to the backend
// so that when the connection is closed,
// the backend can delete the ephemeral node.
if (nodeKey === null) {
setNodeKey(netmap.NodeKey);
submit(
{
node_key: netmap.NodeKey,
auth_key: ipnDetails.PreAuthKey,
},
{ method: 'POST' },
);
}
},
NotifyBrowseToURL: (url) => {
console.log('Browse to URL:', url);
},
NotifyPanicRecover: (message) => {
console.error('Panic recover:', message);
},
});
pause();
const go = new Go(); // Go is defined by wasm_exec.js
WebAssembly.instantiateStreaming(fetch("/hp_ssh.wasm"), go.importObject).then((value) => {
go.run(value.instance);
const handle = TsWasmNet(ipnDetails, {
NotifyState: (state) => {
console.log("State changed:", state);
if (state === "Running") {
setIpn(handle);
}
},
NotifyNetMap: (netmap) => {
// Only set NodeKey if it is not already set and then
// also dispatch that to the backend to track the
// ephemeral node.
//
// We open an SSE connection to the backend
// so that when the connection is closed,
// the backend can delete the ephemeral node.
if (nodeKey === null) {
setNodeKey(netmap.NodeKey);
submit(
{
node_key: netmap.NodeKey,
auth_key: ipnDetails.PreAuthKey,
},
{ method: "POST" },
);
}
},
NotifyBrowseToURL: (url) => {
console.log("Browse to URL:", url);
},
NotifyPanicRecover: (message) => {
console.error("Panic recover:", message);
},
});
handle.Start();
});
}, []);
handle.Start();
});
}, []);
if (!sshDetails.username) {
return <UserPrompt hostname={sshDetails.hostname} />;
}
if (!sshDetails.username) {
return <UserPrompt hostname={sshDetails.hostname} />;
}
return (
<div className="w-screen h-screen bg-headplane-900">
{ipn === null ? (
<div className="mx-auto h-screen flex items-center justify-center">
<Loader2 className="animate-spin size-10 text-headplane-50" />
</div>
) : (
<div className="flex flex-col h-screen">
<XTerm
hostname={sshDetails.hostname}
ipn={ipn}
username={sshDetails.username}
/>
</div>
)}
</div>
);
return (
<div className="bg-headplane-900 h-screen w-screen">
{ipn === null ? (
<div className="mx-auto flex h-screen items-center justify-center">
<Loader2 className="text-headplane-50 size-10 animate-spin" />
</div>
) : (
<div className="flex h-screen flex-col">
<XTerm hostname={sshDetails.hostname} ipn={ipn} username={sshDetails.username} />
</div>
)}
</div>
);
}
+41 -26
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { useEffect, useState } from 'react';
import { Capabilities } from '~/server/web/roles';
import type { Machine, User } from '~/types';
@@ -32,6 +33,18 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const users = apiUsers.map((user) => ({
...user,
machines: nodes.filter((node) => node.user.id === user.id),
profilePicUrl:
context.config.oidc?.profile_picture_source === 'gravatar'
? (() => {
if (!user.email) {
return undefined;
}
const emailHash = user.email.trim().toLowerCase();
const hash = createHash('sha256').update(emailHash).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
})()
: user.profilePicUrl,
}));
const roles = await Promise.all(
@@ -101,32 +114,34 @@ export default function Page({ loaderData }: Route.ComponentProps) {
Manage the users in your network and their permissions.
</p>
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
<table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th className="uppercase text-xs font-bold pb-2">User</th>
<th className="uppercase text-xs font-bold pb-2">Role</th>
<th className="uppercase text-xs font-bold pb-2">Created At</th>
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{users
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => (
<UserRow
key={user.id}
role={loaderData.roles[users.indexOf(user)]}
user={user}
/>
))}
</tbody>
</table>
<div className="overflow-x-auto">
<table className="table-auto w-full rounded-lg min-w-[640px]">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th className="uppercase text-xs font-bold pb-2">User</th>
<th className="uppercase text-xs font-bold pb-2">Role</th>
<th className="uppercase text-xs font-bold pb-2">Created At</th>
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{users
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => (
<UserRow
key={user.id}
role={loaderData.roles[users.indexOf(user)]}
user={user}
/>
))}
</tbody>
</table>
</div>
</>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { versions } from 'node:process';
import { data } from 'react-router';
import type { Route } from './+types/info';
export async function loader({ request, context }: Route.LoaderArgs) {
if (context.config.server.info_secret == null) {
throw data(
{
status: 'Forbidden',
},
403,
);
}
const bearer = request.headers.get('Authorization') ?? '';
if (!bearer.startsWith('Bearer ')) {
throw data(
{
status: 'Unauthorized',
},
401,
);
}
const token = bearer.slice('Bearer '.length).trim();
if (token !== context.config.server.info_secret) {
throw data(
{
status: 'Forbidden',
},
403,
);
}
// Use a fake API key for healthcheck
const api = context.hsApi.getRuntimeClient('fake-api-key');
const healthy = await api.isHealthy();
const body = {
status: healthy ? 'healthy' : 'unhealthy',
headplane_version: __VERSION__,
headscale_canonical_version: healthy ? context.hsApi.apiVersion : 'unknown',
internal_versions: {
node: versions.node,
v8: versions.v8,
uv: versions.uv,
zlib: versions.zlib,
openssl: versions.openssl,
libc: versions.libc,
},
};
return new Response(JSON.stringify(body), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}
+36 -7
View File
@@ -1,4 +1,5 @@
import { type } from 'arktype';
import log from '~/utils/log';
import DockerIntegration from './integration/docker';
import KubernetesIntegration from './integration/kubernetes';
import ProcIntegration from './integration/proc';
@@ -14,7 +15,9 @@ export const pathSupportedKeys = [
const serverConfig = type({
host: 'string.ip = "0.0.0.0"',
port: 'number.integer = 3000',
base_url: 'string.url?',
data_path: 'string.lower = "/var/lib/headplane/"',
info_secret: 'string?',
cookie_secret: '(32 <= string <= 32)',
cookie_secure: 'boolean = true',
@@ -25,7 +28,9 @@ const serverConfig = type({
const partialServerConfig = type({
host: 'string.ip?',
port: 'number.integer?',
base_url: 'string.url?',
data_path: 'string.lower?',
info_secret: 'string?',
cookie_secret: '(32 <= string <= 32)?',
cookie_secure: 'boolean?',
@@ -62,7 +67,32 @@ const oidcConfig = type({
client_id: 'string',
client_secret: 'string',
headscale_api_key: 'string',
redirect_uri: 'string.url?',
use_pkce: 'boolean = false',
redirect_uri: type('string.url')
.pipe((value, ctx) => {
log.warn(
'config',
'%s is deprecated and will be removed in 0.7.0',
ctx.propString,
);
const cleanedValue = new URL(value.trim());
if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) {
cleanedValue.pathname = cleanedValue.pathname.replace(
`${__PREFIX__}/oidc/callback`,
'/',
);
log.warn(
'config',
'Please migrate to using `server.base_url` with a value of "%s"',
cleanedValue.toString(),
);
}
return cleanedValue.toString();
})
.optional(),
disable_api_key_login: 'boolean = false',
scope: 'string = "openid email profile"',
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
@@ -71,19 +101,19 @@ const oidcConfig = type({
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// Old/deprecated options
user_storage_file: 'string.lower = "/var/lib/headplane/users.json"',
strict_validation: type('unknown').narrow(deprecatedField()).optional(),
token_endpoint_auth_method: type('unknown')
.narrow(deprecatedField())
.optional(),
});
const partialOidcConfig = type({
issuer: 'string.url?',
client_id: 'string?',
client_secret: 'string?',
use_pkce: 'boolean?',
headscale_api_key: 'string?',
redirect_uri: 'string.url?',
disable_api_key_login: 'boolean?',
@@ -94,13 +124,12 @@ const partialOidcConfig = type({
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// Old/deprecated options
user_storage_file: 'string.lower?',
strict_validation: type('unknown').narrow(deprecatedField()).optional(),
token_endpoint_auth_method: type('unknown')
.narrow(deprecatedField())
.optional(),
});
const agentConfig = type({
+130 -228
View File
@@ -1,265 +1,167 @@
import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os';
import { join, resolve } from 'node:path';
import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
import { type } from 'arktype';
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
import log from '~/utils/log';
import { Integration } from './abstract';
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";
import { type } from "arktype";
import { readdir, readFile } from "node:fs/promises";
import { platform } from "node:os";
import { join } from "node:path";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log";
import { Integration } from "./abstract";
import { findHeadscaleServe, signalAndWaitHealthy } from "./proc-helper";
// https://github.com/kubernetes-client/javascript/blob/055b83c6504dfd1b2a2d081efd974163c6cbb808/src/config.ts#L40
const svcRoot = '/var/run/secrets/kubernetes.io/serviceaccount';
const svcRoot = "/var/run/secrets/kubernetes.io/serviceaccount";
const svcCaPath = `${svcRoot}/ca.crt`;
const svcTokenPath = `${svcRoot}/token`;
const svcNamespacePath = `${svcRoot}/namespace`;
const configSchema = {
full: type({
enabled: 'boolean',
pod_name: 'string',
validate_manifest: 'boolean = true',
}),
full: type({
enabled: "boolean",
pod_name: "string",
validate_manifest: "boolean = true",
}),
partial: type({
enabled: 'boolean?',
pod_name: 'string?',
validate_manifest: 'boolean?',
}).partial(),
partial: type({
enabled: "boolean?",
pod_name: "string?",
validate_manifest: "boolean?",
}).partial(),
};
export default class KubernetesIntegration extends Integration<
typeof configSchema.full.infer
> {
private pid: number | undefined;
private maxAttempts = 10;
export default class KubernetesIntegration extends Integration<typeof configSchema.full.infer> {
private pid: number | undefined;
get name() {
return 'Kubernetes (k8s)';
}
get name() {
return "Kubernetes (k8s)";
}
static get configSchema() {
return configSchema;
}
static get configSchema() {
return configSchema;
}
async isAvailable() {
if (platform() !== 'linux') {
log.error('config', 'Kubernetes is only available on Linux');
return false;
}
async isAvailable() {
if (platform() !== "linux") {
log.error("config", "Kubernetes is only available on Linux");
return false;
}
try {
log.debug('config', 'Checking Kubernetes service account at %s', svcRoot);
const files = await readdir(svcRoot);
if (files.length === 0) {
log.error('config', 'Kubernetes service account not found');
return false;
}
try {
log.debug("config", "Checking Kubernetes service account at %s", svcRoot);
const files = await readdir(svcRoot);
if (files.length === 0) {
log.error("config", "Kubernetes service account not found");
return false;
}
const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
const expectedFiles = [svcCaPath, svcTokenPath, svcNamespacePath];
const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
const expectedFiles = [svcCaPath, svcTokenPath, svcNamespacePath];
log.debug('config', 'Looking for %s', expectedFiles.join(', '));
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
log.error('config', 'Malformed Kubernetes service account');
return false;
}
} catch (error) {
log.error('config', 'Failed to access %s: %s', svcRoot, error);
return false;
}
log.debug("config", "Looking for %s", expectedFiles.join(", "));
if (!expectedFiles.every((file) => mappedFiles.has(file))) {
log.error("config", "Malformed Kubernetes service account");
return false;
}
} catch (error) {
log.error("config", "Failed to access %s: %s", svcRoot, error);
return false;
}
log.debug('config', 'Reading Kubernetes service account at %s', svcRoot);
const namespace = await readFile(svcNamespacePath, 'utf8');
log.debug("config", "Reading Kubernetes service account at %s", svcRoot);
const namespace = await readFile(svcNamespacePath, "utf8");
// Some very ugly nesting but it's necessary
if (this.context.validate_manifest === false) {
log.warn('config', 'Skipping strict Pod status check');
} else {
const pod = this.context.pod_name;
if (!pod) {
log.error('config', 'Missing POD_NAME variable');
return false;
}
// Some very ugly nesting but it's necessary
if (this.context.validate_manifest === false) {
log.warn("config", "Skipping strict Pod status check");
} else {
const pod = this.context.pod_name;
if (!pod) {
log.error("config", "Missing POD_NAME variable");
return false;
}
if (pod.trim().length === 0) {
log.error('config', 'Pod name is empty');
return false;
}
if (pod.trim().length === 0) {
log.error("config", "Pod name is empty");
return false;
}
log.debug(
'config',
'Checking Kubernetes pod %s in namespace %s',
pod,
namespace,
);
log.debug("config", "Checking Kubernetes pod %s in namespace %s", pod, namespace);
try {
log.debug('config', 'Attempgin to get cluster KubeConfig');
const kc = new KubeConfig();
kc.loadFromCluster();
try {
log.debug("config", "Attempgin to get cluster KubeConfig");
const kc = new KubeConfig();
kc.loadFromCluster();
const cluster = kc.getCurrentCluster();
if (!cluster) {
log.error('config', 'Malformed kubeconfig');
return false;
}
const cluster = kc.getCurrentCluster();
if (!cluster) {
log.error("config", "Malformed kubeconfig");
return false;
}
log.info(
'config',
'Service account connected to %s (%s)',
cluster.name,
cluster.server,
);
log.info("config", "Service account connected to %s (%s)", cluster.name, cluster.server);
const kCoreV1Api = kc.makeApiClient(CoreV1Api);
const kCoreV1Api = kc.makeApiClient(CoreV1Api);
log.info('config', 'Checking pod %s in namespace %s', pod, namespace);
log.debug('config', 'Reading pod info for %s', pod);
const body = await kCoreV1Api.readNamespacedPod({
name: pod,
namespace,
});
log.info("config", "Checking pod %s in namespace %s", pod, namespace);
log.debug("config", "Reading pod info for %s", pod);
const body = await kCoreV1Api.readNamespacedPod({
name: pod,
namespace,
});
if (!body.spec) {
log.error(
'config',
'Missing spec in pod info for %s/%s',
pod,
namespace,
);
if (!body.spec) {
log.error("config", "Missing spec in pod info for %s/%s", pod, namespace);
return false;
}
return false;
}
log.debug('config', 'Got pod info: %o', body.spec);
const shared = body.spec.shareProcessNamespace;
if (shared === undefined) {
log.error(
'config',
'Pod does not have spec.shareProcessNamespace set',
);
log.debug("config", "Got pod info: %o", body.spec);
const shared = body.spec.shareProcessNamespace;
if (shared === undefined) {
log.error("config", "Pod does not have spec.shareProcessNamespace set");
return false;
}
return false;
}
if (!shared) {
log.error(
'config',
'Pod has set but disabled spec.shareProcessNamespace',
);
if (!shared) {
log.error("config", "Pod has set but disabled spec.shareProcessNamespace");
return false;
}
return false;
}
log.info('config', 'Pod %s enabled shared processes', pod);
} catch (error) {
log.error('config', 'Failed to read pod info: %s', error);
return false;
}
}
log.info("config", "Pod %s enabled shared processes", pod);
} catch (error) {
log.error("config", "Failed to read pod info: %s", error);
return false;
}
}
log.debug('config', 'Looking for namespaced process in /proc');
const dir = resolve('/proc');
try {
const subdirs = await readdir(dir);
const promises = subdirs.map(async (dir) => {
const pid = Number.parseInt(dir, 10);
try {
const result = await findHeadscaleServe();
if (!result) {
log.error("config", "Could not find headscale serve process");
return false;
}
if (Number.isNaN(pid)) {
return;
}
this.pid = result;
log.info("config", "Found headscale serve (PID %d)", this.pid);
return true;
} catch (error) {
log.error("config", "Failed to scan /proc: %s", error);
return false;
}
}
const path = join('/proc', dir, 'comm');
try {
log.debug('config', 'Reading %s', path);
const data = await readFile(path, 'utf8');
if (data.trim() !== 'headscale') {
throw new Error(
`Found PID with unexpected command: ${data.trim()}`,
);
}
async onConfigChange(client: RuntimeApiClient) {
if (!this.pid) {
return;
}
return pid;
} catch (error) {
log.debug('config', 'Failed to read %s: %s', path, error);
}
});
const results = await Promise.allSettled(promises);
const pids = [];
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
pids.push(result.value);
}
}
log.debug('config', 'Found Headscale processes: %o', pids);
if (pids.length > 1) {
log.error(
'config',
'Found %d Headscale processes: %s',
pids.length,
pids.join(', '),
);
return false;
}
if (pids.length === 0) {
log.error('config', 'Could not find Headscale process');
return false;
}
this.pid = pids[0];
log.info('config', 'Found Headscale process with PID: %d', this.pid);
return true;
} catch {
log.error('config', 'Failed to read /proc');
return false;
}
}
async onConfigChange(client: RuntimeApiClient) {
if (!this.pid) {
return;
}
try {
log.info('config', 'Sending SIGTERM to Headscale');
kill(this.pid, 'SIGTERM');
} catch (error) {
log.error('config', 'Failed to send SIGTERM to Headscale: %s', error);
log.debug('config', 'kill(1) error: %o', error);
}
await setTimeout(1000);
let attempts = 0;
while (attempts <= this.maxAttempts) {
try {
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.isHealthy();
if (status === false) {
throw new Error('Headscale is not running');
}
log.info('config', 'Headscale is up and running');
return;
} catch {
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
continue;
}
log.error(
'config',
'Missed restart deadline for Headscale (pid %d)',
this.pid,
);
return;
}
}
}
await signalAndWaitHealthy(client, {
pid: this.pid,
signal: "SIGHUP",
});
}
}
@@ -0,0 +1,116 @@
import { readdir, readFile } from "node:fs/promises";
import { join } from "node:path";
import { kill } from "node:process";
import { setTimeout } from "node:timers/promises";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log";
/**
* Does a two-stage scan of /proc to find the headscale process that is running
* a "serve" subcommand. It first scans all processes' comm files to find
* headscale processes, then checks their cmdline files to see if "serve" is
* the second argument.
*
* @param procPath The path to the proc filesystem (default: /proc)
* @returns The PID of the headscale serve process, or undefined if not found
*/
export async function findHeadscaleServe(procPath = "/proc"): Promise<number | undefined> {
const subdirs = await readdir(procPath);
const commResults = await Promise.allSettled(
subdirs.map(async (entry) => {
const pid = Number.parseInt(entry, 10);
if (Number.isNaN(pid)) {
return undefined;
}
try {
const comm = await readFile(join(procPath, entry, "comm"), "utf8");
return comm.trim() === "headscale" ? pid : undefined;
} catch {
return undefined;
}
}),
);
const headscalePids = commResults
.map((result) => {
if (result.status === "fulfilled" && result.value !== undefined) {
return result.value;
}
return undefined;
})
.filter((pid): pid is number => pid !== undefined);
if (headscalePids.length === 0) {
return undefined;
}
log.debug("config", "Found %d headscale process(es), checking for serve", headscalePids.length);
for (const pid of headscalePids) {
try {
const cmdline = await readFile(join(procPath, pid.toString(), "cmdline"), "utf8");
const args = cmdline.split("\0").filter(Boolean);
if (args[1] === "serve") {
return pid;
}
} catch {
// Process may have exited between stages
}
}
return undefined;
}
/**
* Options for signaling the headscale process.
*/
export interface SignalHeadscaleOptions {
pid: number;
signal?: NodeJS.Signals;
maxAttempts?: number;
retryDelayMs?: number;
}
/**
* Sends a signal to the headscale process and waits for it to become healthy.
* @param client The RuntimeApiClient to check health
* @param options Options for signaling and waiting
* @returns True if headscale became healthy, false otherwise
*/
export async function signalAndWaitHealthy(
client: RuntimeApiClient,
options: SignalHeadscaleOptions,
): Promise<boolean> {
const { pid, signal = "SIGHUP", maxAttempts = 10, retryDelayMs = 1000 } = options;
try {
kill(pid, signal);
log.info("config", "Sent %s to Headscale (PID %d)", signal, pid);
} catch (error) {
log.error("config", "Failed to send %s to PID %d: %s", signal, pid, error);
return false;
}
await setTimeout(retryDelayMs);
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const healthy = await client.isHealthy();
if (healthy) {
log.info("config", "Headscale is healthy after restart");
return true;
}
} catch {
// Still restarting
}
if (attempt < maxAttempts) {
await setTimeout(retryDelayMs);
}
}
log.error("config", "Headscale did not become healthy after %d attempts", maxAttempts);
return false;
}
+51 -162
View File
@@ -1,175 +1,64 @@
import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os';
import { join, resolve } from 'node:path';
import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { type } from 'arktype';
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
import log from '~/utils/log';
import type { HeadplaneConfig } from '../config-schema';
import { Integration } from './abstract';
import { type } from "arktype";
import { platform } from "node:os";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log";
import { Integration } from "./abstract";
import { findHeadscaleServe, signalAndWaitHealthy } from "./proc-helper";
const configSchema = {
full: type({
enabled: 'boolean',
}),
full: type({
enabled: "boolean",
}),
partial: type({
enabled: 'boolean?',
}).partial(),
partial: type({
enabled: "boolean?",
}).partial(),
};
export default class ProcIntegration extends Integration<
typeof configSchema.full.infer
> {
private pid: number | undefined;
private maxAttempts = 10;
export default class ProcIntegration extends Integration<typeof configSchema.full.infer> {
private pid: number | undefined;
get name() {
return 'Native Linux (/proc)';
}
get name() {
return "Native Linux (/proc)";
}
static get configSchema() {
return configSchema;
}
static get configSchema() {
return configSchema;
}
async isAvailable() {
if (platform() !== 'linux') {
log.error('config', '/proc is only available on Linux');
return false;
}
async isAvailable() {
if (platform() !== "linux") {
log.error("config", "/proc is only available on Linux");
return false;
}
log.debug('config', 'Checking /proc for Headscale process');
const dir = resolve('/proc');
try {
const subdirs = await readdir(dir);
const promises = subdirs.map(async (dir) => {
const pid = Number.parseInt(dir, 10);
try {
const result = await findHeadscaleServe();
if (!result) {
log.error("config", "Could not find headscale serve process");
return false;
}
if (Number.isNaN(pid)) {
return;
}
this.pid = result;
log.info("config", "Found headscale serve (PID %d)", this.pid);
return true;
} catch (error) {
log.error("config", "Failed to scan /proc: %s", error);
return false;
}
}
const path = join('/proc', dir, 'comm');
try {
log.debug('config', 'Reading %s', path);
const data = await readFile(path, 'utf8');
if (data.trim() !== 'headscale') {
throw new Error(
`Found PID with unexpected command: ${data.trim()}`,
);
}
async onConfigChange(client: RuntimeApiClient) {
if (!this.pid) {
return;
}
return pid;
} catch (error) {
log.debug('config', 'Failed to read %s: %s', path, error);
}
});
const results = await Promise.allSettled(promises);
const pids = [];
for (const result of results) {
if (result.status === 'fulfilled' && result.value) {
pids.push(result.value);
}
}
log.debug('config', 'Found Headscale processes: %o', pids);
if (pids.length > 1) {
log.warn(
'config',
'Found %d Headscale processes: %s',
pids.length,
pids.join(', '),
);
log.debug(
'config',
'Checking if any of them have Parent PID = 1, assuming thats the correct PID',
);
const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/;
for (const pid of pids) {
const pidStatusPath = join('/proc', pid.toString(), 'status');
try {
log.debug('config', 'Reading %s', pidStatusPath);
const pidData = await readFile(pidStatusPath, 'utf8');
const ppidResult = pidData.match(ppidRegex);
if (ppidResult !== null) {
const potentialPPid = Number.parseInt(ppidResult[1], 10);
if (potentialPPid === 1) {
this.pid = pid;
log.info(
'config',
'Found potential Headscale process with PID: %d based on Parent PID = 1',
this.pid,
);
return true;
}
}
} catch (error) {
log.error('config', 'Failed to read %s: %s', pidStatusPath, error);
}
}
return false;
}
if (pids.length === 0) {
log.error('config', 'Could not find Headscale process');
return false;
}
this.pid = pids[0];
log.info('config', 'Found Headscale process with PID: %d', this.pid);
return true;
} catch {
log.error('config', 'Failed to read /proc');
return false;
}
}
async onConfigChange(client: RuntimeApiClient) {
if (!this.pid) {
return;
}
try {
log.info('config', 'Sending SIGTERM to Headscale');
kill(this.pid, 'SIGTERM');
} catch (error) {
log.error('config', 'Failed to send SIGTERM to Headscale: %s', error);
log.debug('config', 'kill(1) error: %o', error);
}
await setTimeout(1000);
let attempts = 0;
while (attempts <= this.maxAttempts) {
try {
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.isHealthy();
if (status === false) {
log.error('config', 'Headscale is not running');
return;
}
log.info('config', 'Headscale is up and running');
return;
} catch {
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
continue;
}
log.error(
'config',
'Missed restart deadline for Headscale (pid %d)',
this.pid,
);
return;
}
}
}
await signalAndWaitHealthy(client, {
pid: this.pid,
signal: "SIGHUP",
});
}
}
+143 -125
View File
@@ -1,150 +1,168 @@
import type { Machine } from '~/types';
import { defineApiEndpoints } from '../factory';
import type { Machine } from "~/types";
import type { HeadscaleApiInterface } from "..";
import { defineApiEndpoints } from "../factory";
interface RawMachine extends Omit<Machine, "tags"> {
tags?: string[];
forcedTags?: string[];
validTags?: string[];
invalidTags?: string[];
}
/**
* Normalizes the tags of a RawMachine based on the Headscale version.
*
* @param client The Headscale API client helper.
* @param node The RawMachine object to normalize.
* @returns A Machine object with normalized tags.
*/
function normalizeTags(client: HeadscaleApiInterface["clientHelpers"], node: RawMachine): Machine {
if (client.isAtleast("0.28.0")) {
return { ...node, tags: node.tags ?? [] } as Machine;
}
const tags = Array.from(new Set([...(node.forcedTags ?? []), ...(node.validTags ?? [])]));
return { ...node, tags } as Machine;
}
export interface NodeEndpoints {
/**
* Retrieves all nodes (machines) from the Headscale instance.
*
* @returns An array of `Machine` objects representing the nodes.
*/
getNodes(): Promise<Machine[]>;
/**
* Retrieves all nodes (machines) from the Headscale instance.
*
* @returns An array of `Machine` objects representing the nodes.
*/
getNodes(): Promise<Machine[]>;
/**
* Retrieves a specific node (machine) by its ID.
*
* @param id The ID of the node to retrieve.
* @returns A `Machine` object representing the node.
*/
getNode(id: string): Promise<Machine>;
/**
* Retrieves a specific node (machine) by its ID.
*
* @param id The ID of the node to retrieve.
* @returns A `Machine` object representing the node.
*/
getNode(id: string): Promise<Machine>;
/**
* Deletes a specific node (machine) by its ID.
*
* @param id The ID of the node to delete.
*/
deleteNode(id: string): Promise<void>;
/**
* Deletes a specific node (machine) by its ID.
*
* @param id The ID of the node to delete.
*/
deleteNode(id: string): Promise<void>;
/**
* Registers a new node (machine) with the given user and key.
*
* @param user The user to associate with the node.
* @param key The registration key for the node.
* @returns A `Machine` object representing the newly registered node.
*/
registerNode(user: string, key: string): Promise<Machine>;
/**
* Registers a new node (machine) with the given user and key.
*
* @param user The user to associate with the node.
* @param key The registration key for the node.
* @returns A `Machine` object representing the newly registered node.
*/
registerNode(user: string, key: string): Promise<Machine>;
/**
* Approves routes for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param routes An array of routes to approve for the node.
*/
approveNodeRoutes(id: string, routes: string[]): Promise<void>;
/**
* Approves routes for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param routes An array of routes to approve for the node.
*/
approveNodeRoutes(id: string, routes: string[]): Promise<void>;
/**
* Expires a specific node (machine) by its ID.
*
* @param id The ID of the node to expire.
*/
expireNode(id: string): Promise<void>;
/**
* Expires a specific node (machine) by its ID.
*
* @param id The ID of the node to expire.
*/
expireNode(id: string): Promise<void>;
/**
* Renames a specific node (machine) by its ID.
*
* @param id The ID of the node to rename.
* @param newName The new name for the node.
*/
renameNode(id: string, newName: string): Promise<void>;
/**
* Renames a specific node (machine) by its ID.
*
* @param id The ID of the node to rename.
* @param newName The new name for the node.
*/
renameNode(id: string, newName: string): Promise<void>;
/**
* Sets tags for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param tags An array of tags to set for the node.
*/
setNodeTags(id: string, tags: string[]): Promise<void>;
/**
* Sets tags for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param tags An array of tags to set for the node.
*/
setNodeTags(id: string, tags: string[]): Promise<void>;
/**
* Sets the user for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param user The user to set for the node.
*/
setNodeUser(id: string, user: string): Promise<void>;
/**
* Sets the user for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param user The user to set for the node.
*/
setNodeUser(id: string, user: string): Promise<void>;
}
export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
getNodes: async () => {
const { nodes } = await client.apiFetch<{ nodes: Machine[] }>(
'GET',
'v1/node',
apiKey,
);
getNodes: async () => {
const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey);
return nodes.map((node) => normalizeTags(client, node));
},
return nodes;
},
getNode: async (nodeId) => {
const { node } = await client.apiFetch<{ node: RawMachine }>(
"GET",
`v1/node/${nodeId}`,
apiKey,
);
getNode: async (nodeId) => {
const { node } = await client.apiFetch<{ node: Machine }>(
'GET',
`v1/node/${nodeId}`,
apiKey,
);
return normalizeTags(client, node);
},
return node;
},
deleteNode: async (nodeId) => {
await client.apiFetch<void>("DELETE", `v1/node/${nodeId}`, apiKey);
},
deleteNode: async (nodeId) => {
await client.apiFetch<void>('DELETE', `v1/node/${nodeId}`, apiKey);
},
registerNode: async (user, key) => {
const qp = new URLSearchParams();
qp.append("user", user);
qp.append("key", key);
const { node } = await client.apiFetch<{ node: RawMachine }>(
"POST",
`v1/node/register?${qp.toString()}`,
apiKey,
{
user,
key,
},
);
registerNode: async (user, key) => {
const qp = new URLSearchParams();
qp.append('user', user);
qp.append('key', key);
const { node } = await client.apiFetch<{ node: Machine }>(
'POST',
`v1/node/register?${qp.toString()}`,
apiKey,
{
user,
key,
},
);
return normalizeTags(client, node);
},
return node;
},
approveNodeRoutes: async (nodeId, routes) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/approve_routes`, apiKey, { routes });
},
approveNodeRoutes: async (nodeId, routes) => {
await client.apiFetch<void>(
'POST',
`v1/node/${nodeId}/approve_routes`,
apiKey,
{ routes },
);
},
expireNode: async (nodeId) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/expire`, apiKey);
},
expireNode: async (nodeId) => {
await client.apiFetch<void>('POST', `v1/node/${nodeId}/expire`, apiKey);
},
renameNode: async (nodeId, newName) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey);
},
renameNode: async (nodeId, newName) => {
await client.apiFetch<void>(
'POST',
`v1/node/${nodeId}/rename/${newName}`,
apiKey,
);
},
setNodeTags: async (nodeId, tags) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/tags`, apiKey, {
tags,
});
},
setNodeTags: async (nodeId, tags) => {
await client.apiFetch<void>('POST', `v1/node/${nodeId}/tags`, apiKey, {
tags,
});
},
setNodeUser: async (nodeId, user) => {
// Headscale 0.28.0 got rid of node reassignment to users
if (client.isAtleast("0.28.0")) {
return;
}
setNodeUser: async (nodeId, user) => {
await client.apiFetch<void>('POST', `v1/node/${nodeId}/user`, apiKey, {
user,
});
},
await client.apiFetch<void>("POST", `v1/node/${nodeId}/user`, apiKey, {
user,
});
},
}));
+27
View File
@@ -44,6 +44,13 @@ export function isConnectionError(
);
}
/**
* Type guard to check if an error is a DataUnauthorizedError.
* This checks if the error has a `data` property with a `statusCode` of 401.
*
* @param error - The error to check.
* @returns True if the error is a DataUnauthorizedError, false otherwise.
*/
export function isDataUnauthorizedError(error: unknown): boolean {
return (
error != null &&
@@ -55,3 +62,23 @@ export function isDataUnauthorizedError(error: unknown): boolean {
error.data.statusCode === 401
);
}
/**
* Type guard to check if an error is a DataWithResponseInit wrapping a
* HeadscaleAPIError. This is used in loaders/actions to handle errors thrown by
* `data()` before they reach the ErrorBoundary.
*
* @param error - The error to check.
* @returns True if the error is a DataWithResponseInit containing a
* HeadscaleAPIError, false otherwise.
*/
export function isDataWithApiError(
error: unknown,
): error is { data: HeadscaleAPIError } {
return (
error != null &&
typeof error === 'object' &&
'data' in error &&
isApiError(error.data)
);
}
@@ -1,21 +0,0 @@
// Represents an error that occurred during a response
// Thrown when status codes are >= 400
export default class ResponseError extends Error {
status: number;
response: string;
requestUrl: string;
responseObject?: Record<string, unknown>;
constructor(status: number, response: string, requestUrl: string) {
super(`${requestUrl}: status ${status} - ${response}`);
this.name = 'ResponseError';
this.status = status;
this.response = response;
this.requestUrl = requestUrl;
try {
// Try to parse the response as JSON to get a response object
this.responseObject = JSON.parse(response);
} catch {}
}
}
+98 -102
View File
@@ -1,141 +1,137 @@
import { join } from 'node:path';
import { exit, versions } from 'node:process';
import { createHonoServer } from 'react-router-hono-server/node';
import log from '~/utils/log';
import { loadIntegration } from './config/integration';
import { loadConfig } from './config/load';
import { createDbClient } from './db/client.server';
import { createHeadscaleInterface } from './headscale/api';
import { loadHeadscaleConfig } from './headscale/config-loader';
import { createHeadplaneAgent } from './hp-agent';
import { createSessionStorage } from './web/sessions';
import { join } from "node:path";
import { exit, versions } from "node:process";
import { createHonoServer } from "react-router-hono-server/node";
import log from "~/utils/log";
import { loadIntegration } from "./config/integration";
import { loadConfig } from "./config/load";
import { createDbClient } from "./db/client.server";
import { createHeadscaleInterface } from "./headscale/api";
import { loadHeadscaleConfig } from "./headscale/config-loader";
import { createHeadplaneAgent } from "./hp-agent";
import { createSessionStorage } from "./web/sessions";
declare global {
const __PREFIX__: string;
const __VERSION__: string;
const __PREFIX__: string;
const __VERSION__: string;
}
// MARK: Side-Effects
// This module contains a side-effect because everything running here
// exists for the lifetime of the process, making it appropriate.
log.info('server', 'Running Node.js %s', versions.node);
log.info("server", "Running Node.js %s", versions.node);
let config: HeadplaneConfig;
try {
config = await loadConfig();
config = await loadConfig();
} catch (error) {
if (error instanceof ConfigError) {
log.error('server', 'Unable to load configuration: %s', error.message);
}
if (error instanceof ConfigError) {
log.error("server", "Unable to load configuration: %s", error.message);
}
exit(1);
exit(1);
}
const db = await createDbClient(join(config.server.data_path, 'hp_persist.db'));
const agents = await createHeadplaneAgent(
config.integration?.agent,
config.headscale.url,
db,
);
const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));
const agents = await createHeadplaneAgent(config.integration?.agent, config.headscale.url, db);
const hsApi = await createHeadscaleInterface(
config.headscale.url,
config.headscale.tls_cert_path,
);
const hsApi = await createHeadscaleInterface(config.headscale.url, config.headscale.tls_cert_path);
// We also use this file to load anything needed by the react router code.
// These are usually per-request things that we need access to, like the
// helper that can issue and revoke cookies.
export type LoadContext = typeof appLoadContext;
import 'react-router';
import { HeadplaneConfig } from './config/config-schema';
import { ConfigError } from './config/error';
import { createOidcConnector } from './web/oidc-connector';
import "react-router";
import { HeadplaneConfig } from "./config/config-schema";
import { ConfigError } from "./config/error";
import { createLazyOidcConnector } from "./web/oidc-connector";
declare module 'react-router' {
interface AppLoadContext extends LoadContext {}
declare module "react-router" {
interface AppLoadContext extends LoadContext {}
}
const appLoadContext = {
config,
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
),
config,
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
),
// TODO: Better cookie options in config
sessions: await createSessionStorage({
secret: config.server.cookie_secret,
db,
oidcUsersFile: config.oidc?.user_storage_file,
cookie: {
name: '_hp_auth',
secure: config.server.cookie_secure,
maxAge: config.server.cookie_max_age,
domain: config.server.cookie_domain,
},
}),
// TODO: Better cookie options in config
sessions: await createSessionStorage({
secret: config.server.cookie_secret,
db,
oidcUsersFile: config.oidc?.user_storage_file,
cookie: {
name: "_hp_auth",
secure: config.server.cookie_secure,
maxAge: config.server.cookie_max_age,
domain: config.server.cookie_domain,
},
}),
hsApi,
agents,
integration: await loadIntegration(config.integration),
oidcConnector: config.oidc
? await createOidcConnector(
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
: undefined,
db,
hsApi,
agents,
integration: await loadIntegration(config.integration),
oidcConnector: config.oidc
? createLazyOidcConnector(
config.server.base_url,
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
: undefined,
db,
};
declare module 'react-router' {
interface AppLoadContext extends LoadContext {}
declare module "react-router" {
interface AppLoadContext extends LoadContext {}
}
export default createHonoServer({
overrideGlobalObjects: true,
port: config.server.port,
hostname: config.server.host,
beforeAll: async (app) => {
app.use(__PREFIX__, async (c) => {
return c.redirect(`${__PREFIX__}/`);
});
},
serveStaticOptions: {
publicAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ''),
},
clientAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ''),
},
},
overrideGlobalObjects: true,
port: config.server.port,
hostname: config.server.host,
beforeAll: async (app) => {
app.use(__PREFIX__, async (c) => {
return c.redirect(`${__PREFIX__}/`);
});
},
serveStaticOptions: {
publicAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""),
},
clientAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""),
},
},
// Only log in development mode
defaultLogger: import.meta.env.DEV,
getLoadContext() {
// TODO: This is the place where we can handle reverse proxy translation
// This is better than doing it in the OIDC client, since we can do it
// for all requests, not just OIDC ones.
return appLoadContext;
},
// Only log in development mode
defaultLogger: import.meta.env.DEV,
getLoadContext() {
// TODO: This is the place where we can handle reverse proxy translation
// This is better than doing it in the OIDC client, since we can do it
// for all requests, not just OIDC ones.
return appLoadContext;
},
listeningListener(info) {
log.info('server', 'Running on %s:%s', info.address, info.port);
},
listeningListener(info) {
log.info("server", "Running on %s:%s", info.address, info.port);
},
});
process.on('SIGINT', () => {
log.info('server', 'Received SIGINT, shutting down...');
process.exit(0);
process.on("SIGINT", () => {
log.info("server", "Received SIGINT, shutting down...");
process.exit(0);
});
process.on('SIGTERM', () => {
log.info('server', 'Received SIGTERM, shutting down...');
process.exit(0);
process.on("SIGTERM", () => {
log.info("server", "Received SIGTERM, shutting down...");
process.exit(0);
});
+273 -148
View File
@@ -1,99 +1,183 @@
import * as oidc from 'openid-client';
import log from '~/utils/log';
import type { HeadplaneConfig } from '../config/config-schema';
import type { RuntimeApiClient } from '../headscale/api/endpoints';
import { isDataUnauthorizedError } from '../headscale/api/error-client';
import * as oidc from "openid-client";
export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
import log from "~/utils/log";
import type { HeadplaneConfig } from "../config/config-schema";
import type { RuntimeApiClient } from "../headscale/api/endpoints";
import { isDataUnauthorizedError } from "../headscale/api/error-client";
export type OidcConfig = NonNullable<HeadplaneConfig["oidc"]>;
/**
* Errors that can occur during OIDC connector setup and validation.
*/
export type OidcConnectorError =
| 'INVALID_API_KEY'
| 'MISSING_AUTHORIZATION_ENDPOINT'
| 'MISSING_TOKEN_ENDPOINT'
| 'MISSING_USERINFO_ENDPOINT'
| 'MISSING_REQUIRED_CLAIMS'
| 'UNKNOWN_ERROR';
| "INVALID_API_KEY"
| "MISSING_AUTHORIZATION_ENDPOINT"
| "MISSING_TOKEN_ENDPOINT"
| "MISSING_USERINFO_ENDPOINT"
| "MISSING_REQUIRED_CLAIMS"
| "DISCOVERY_FAILED"
| "UNKNOWN_ERROR";
/**
* Represents a "configured" OIDC setup for Headplane.
* This may include mis-configured versions too and will surface error messages.
*/
export type OidcConnector =
| {
isValid: true;
isExclusive: boolean;
client: oidc.Configuration;
apiKey: string;
scope: string;
extraParams?: Record<string, string>;
}
| {
isValid: false;
isExclusive: false;
errors: OidcConnectorError[];
};
| {
isValid: true;
isExclusive: boolean;
usePKCE: boolean;
client: oidc.Configuration;
apiKey: string;
scope: string;
extraParams?: Record<string, string>;
}
| {
isValid: false;
isExclusive: false;
errors: OidcConnectorError[];
};
/**
* A lazy OIDC connector that retries initialization on failure.
* This allows OIDC to recover from transient startup failures (e.g., network issues,
* OIDC provider temporarily unavailable) without requiring a server restart.
*/
export interface LazyOidcConnector {
/**
* Get the current OIDC connector state.
* If a previous attempt failed, this will retry initialization.
* Successful results are cached until invalidated.
*/
get(): Promise<OidcConnector>;
/**
* Force a re-initialization of the OIDC connector on the next get() call.
* Useful for manually triggering a retry after configuration changes.
*/
invalidate(): void;
}
/**
* Creates a lazy OIDC connector that retries on failure.
* Successful initialization is cached; failed attempts are retried on each get() call.
*
* @param baseUrl The base URL of the Headplane server.
* @param config The OIDC configuration.
* @param client The Headscale runtime API client.
* @returns A lazy OIDC connector that retries on failure.
*/
export function createLazyOidcConnector(
baseUrl: string | undefined,
config: OidcConfig,
client: RuntimeApiClient,
): LazyOidcConnector {
let cachedConnector: OidcConnector | undefined;
let initPromise: Promise<OidcConnector> | undefined;
return {
async get(): Promise<OidcConnector> {
if (cachedConnector?.isValid) {
return cachedConnector;
}
if (initPromise) {
return initPromise;
}
initPromise = createOidcConnector(baseUrl, config, client);
try {
const connector = await initPromise;
if (connector.isValid) {
cachedConnector = connector;
log.info("auth", "OIDC connector initialized successfully");
} else {
log.warn("auth", "OIDC connector initialization failed, will retry on next request");
}
return connector;
} finally {
// Clear the promise so we can retry on next call if it failed
initPromise = undefined;
}
},
invalidate(): void {
cachedConnector = undefined;
initPromise = undefined;
log.info("auth", "OIDC connector cache invalidated");
},
};
}
/**
* Creates an OIDC connector based on the configuration and Headscale API.
* This will attempt to validate the configuration and return any errors.
*
* @param baseUrl The base URL of the Headplane server.
* @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,
async function createOidcConnector(
baseUrl: string | undefined,
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,
};
}
if (baseUrl == null && config.redirect_uri == null) {
log.warn(
"config",
"OIDC is enabled but `server.base_url` is not set in the config. Starting in Headplane 0.7.0 this will be required for OIDC to function properly and will throw errors if not set, see https://headplane.net/features/sso#configuring-oidc for more information.",
);
}
try {
await client.getApiKeys();
} catch (error) {
if (isDataUnauthorizedError(error)) {
errors.push('INVALID_API_KEY');
return {
isValid: false,
isExclusive: false,
errors,
};
}
const errors: OidcConnectorError[] = [];
if (!config.headscale_api_key) {
errors.push("INVALID_API_KEY");
return {
isValid: false,
isExclusive: false,
errors,
};
}
// MARK: Otherwise assume the API key is valid since the API request
// failed for another reason that isn't 401 and we are optimistic
}
try {
await client.getApiKeys();
} catch (error) {
if (isDataUnauthorizedError(error)) {
errors.push("INVALID_API_KEY");
return {
isValid: false,
isExclusive: false,
errors,
};
}
const oidcClientOrErrors = await discoveryCoalesce(config);
if (Array.isArray(oidcClientOrErrors)) {
errors.push(...oidcClientOrErrors);
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
}
return {
isValid: true,
isExclusive: config.disable_api_key_login,
client: oidcClientOrErrors,
apiKey: config.headscale_api_key,
scope: config.scope,
extraParams: config.extra_params,
};
const oidcClientOrErrors = await discoveryCoalesce(config);
if (Array.isArray(oidcClientOrErrors)) {
errors.push(...oidcClientOrErrors);
return {
isValid: false,
isExclusive: false,
errors,
};
}
return {
isValid: true,
isExclusive: config.disable_api_key_login,
usePKCE: config.use_pkce,
client: oidcClientOrErrors,
apiKey: config.headscale_api_key,
scope: config.scope,
extraParams: config.extra_params,
};
}
/**
@@ -104,94 +188,135 @@ export async function createOidcConnector(
* @returns The coalesced OIDC configuration or an array of errors.
*/
async function discoveryCoalesce(
config: OidcConfig,
config: OidcConfig,
): Promise<oidc.Configuration | OidcConnectorError[]> {
let metadata: oidc.ServerMetadata;
try {
const client = await oidc.discovery(
new URL(config.issuer),
config.client_id,
);
metadata = client.serverMetadata();
if (metadata.claims_supported != null) {
if (!metadata.claims_supported.includes('sub')) {
log.error('config', 'OIDC provider does not support `sub` claim');
return ['MISSING_REQUIRED_CLAIMS'];
}
let metadata: oidc.ServerMetadata;
let discoveryFailed = false;
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',
);
}
}
try {
const client = await oidc.discovery(new URL(config.issuer), config.client_id);
metadata = client.serverMetadata();
if (config.use_pkce === true && !client.serverMetadata().supportsPKCE()) {
log.warn("config", "OIDC provider does not support PKCE, but it is enabled in the config");
}
if (
!metadata.claims_supported.includes('preferred_username') &&
!metadata.claims_supported.includes('email')
) {
log.warn(
'config',
'OIDC provider does not support `preferred_username` or `email` claims',
);
}
}
} catch {
log.error(
'config',
'Failed to auto-configure OIDC endpoints via discovery',
);
log.warn(
'config',
'OIDC server may not support discovery, using manual config',
);
metadata = {
issuer: config.issuer,
};
}
if (metadata.claims_supported != null) {
if (!metadata.claims_supported.includes("sub")) {
log.error("config", "OIDC provider does not support `sub` claim");
return ["MISSING_REQUIRED_CLAIMS"];
}
const errors: OidcConnectorError[] = [];
const authorization_endpoint =
config.authorization_endpoint ?? metadata.authorization_endpoint;
if (!metadata.claims_supported.includes("name")) {
if (
!(
metadata.claims_supported.includes("given_name") &&
metadata.claims_supported.includes("family_name")
)
) {
log.warn(
"config",
"OIDC provider does not support `name`, `given_name`, or `family_name` claims",
);
}
}
const token_endpoint = config.token_endpoint ?? metadata.token_endpoint;
if (
!metadata.claims_supported.includes("preferred_username") &&
!metadata.claims_supported.includes("email")
) {
log.warn("config", "OIDC provider does not support `preferred_username` or `email` claims");
}
}
} catch {
log.warn("oidc", "Failed to reach OIDC provider for discovery, will retry on next request");
discoveryFailed = true;
metadata = {
issuer: config.issuer,
};
}
const userinfo_endpoint =
config.userinfo_endpoint ?? metadata.userinfo_endpoint;
const authorization_endpoint = config.authorization_endpoint ?? metadata.authorization_endpoint;
const token_endpoint = config.token_endpoint ?? metadata.token_endpoint;
const userinfo_endpoint = config.userinfo_endpoint ?? metadata.userinfo_endpoint;
if (!authorization_endpoint) {
errors.push('MISSING_AUTHORIZATION_ENDPOINT');
}
const hasMissingEndpoints = !authorization_endpoint || !token_endpoint || !userinfo_endpoint;
if (!token_endpoint) {
errors.push('MISSING_TOKEN_ENDPOINT');
}
if (discoveryFailed && hasMissingEndpoints) {
return ["DISCOVERY_FAILED"];
}
if (!userinfo_endpoint) {
errors.push('MISSING_USERINFO_ENDPOINT');
}
const errors: OidcConnectorError[] = [];
if (errors.length > 0) {
return errors;
}
if (!authorization_endpoint) {
errors.push("MISSING_AUTHORIZATION_ENDPOINT");
}
const oidcClient = new oidc.Configuration(
{
issuer: config.issuer,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
},
config.client_id,
config.client_secret,
);
if (!token_endpoint) {
errors.push("MISSING_TOKEN_ENDPOINT");
}
return oidcClient;
if (!userinfo_endpoint) {
errors.push("MISSING_USERINFO_ENDPOINT");
}
if (errors.length > 0) {
return errors;
}
const oidcClient = new oidc.Configuration(
{
...metadata,
issuer: config.issuer,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
},
config.client_id,
config.client_secret,
negotiateTokenEndpointAuthMethod(config, metadata),
);
return oidcClient;
}
/**
* Determines the token endpoint authentication method based on config and metadata.
*
* @param config The OIDC configuration.
* @param metadata The OIDC server metadata.
* @returns The client authentication method for the token endpoint.
*/
function negotiateTokenEndpointAuthMethod(
config: OidcConfig,
metadata: oidc.ServerMetadata,
): oidc.ClientAuth {
if (config.token_endpoint_auth_method != null) {
switch (config.token_endpoint_auth_method) {
case "client_secret_basic":
return oidc.ClientSecretBasic(config.client_secret);
case "client_secret_post":
return oidc.ClientSecretPost(config.client_secret);
case "client_secret_jwt":
return oidc.ClientSecretJwt(config.client_secret);
}
}
const supported = metadata.token_endpoint_auth_methods_supported;
if (supported != null && supported.length > 0) {
// Prefer client_secret_basic (spec default), otherwise use first available
if (supported.includes("client_secret_basic")) {
return oidc.ClientSecretBasic(config.client_secret);
}
if (supported.includes("client_secret_post")) {
return oidc.ClientSecretPost(config.client_secret);
}
if (supported.includes("client_secret_jwt")) {
return oidc.ClientSecretJwt(config.client_secret);
}
}
log.warn("config", "Falling back to client_secret_post for token endpoint authentication");
return oidc.ClientSecretPost(config.client_secret);
}
+71 -75
View File
@@ -4,113 +4,109 @@
@plugin "tailwindcss-react-aria-components";
@theme {
--blur-xs: 2px;
--blur-xs: 2px;
--height-editor: calc(100vh - 20rem);
--height-editor: calc(100vh - 20rem);
--font-sans: Inter, -apple-system, BlinkMacSystemFont, Helvetica, Arial,
sans-serif;
--font-sans: Inter, -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
--transition-duration-25: 25ms;
--transition-duration-50: 50ms;
--transition-duration-25: 25ms;
--transition-duration-50: 50ms;
--color-main-50: #f8fafc;
--color-main-100: #f1f5f9;
--color-main-200: #e2e8f0;
--color-main-300: #cbd5e1;
--color-main-400: #94a3b8;
--color-main-500: #64748b;
--color-main-600: #475569;
--color-main-700: #334155;
--color-main-800: #1e293b;
--color-main-900: #0f172a;
--color-main-950: #020617;
--color-main-50: #f8fafc;
--color-main-100: #f1f5f9;
--color-main-200: #e2e8f0;
--color-main-300: #cbd5e1;
--color-main-400: #94a3b8;
--color-main-500: #64748b;
--color-main-600: #475569;
--color-main-700: #334155;
--color-main-800: #1e293b;
--color-main-900: #0f172a;
--color-main-950: #020617;
--color-ui-50: #fafafa;
--color-ui-100: #f5f5f5;
--color-ui-200: #e5e5e5;
--color-ui-300: #d4d4d4;
--color-ui-400: #a3a3a3;
--color-ui-500: #737373;
--color-ui-600: #525252;
--color-ui-700: #404040;
--color-ui-800: #262626;
--color-ui-900: #171717;
--color-ui-950: #0a0a0a;
--color-ui-50: #fafafa;
--color-ui-100: #f5f5f5;
--color-ui-200: #e5e5e5;
--color-ui-300: #d4d4d4;
--color-ui-400: #a3a3a3;
--color-ui-500: #737373;
--color-ui-600: #525252;
--color-ui-700: #404040;
--color-ui-800: #262626;
--color-ui-900: #171717;
--color-ui-950: #0a0a0a;
--color-headplane-50: #f2f2f2;
--color-headplane-100: #e6e6e6;
--color-headplane-200: #cccccc;
--color-headplane-300: #b3b3b3;
--color-headplane-400: #999999;
--color-headplane-500: #808080;
--color-headplane-600: #666666;
--color-headplane-700: #4d4d4d;
--color-headplane-800: #343434;
--color-headplane-900: #1a1a1a;
--color-headplane-950: #0d0d0d;
--color-headplane-50: #f2f2f2;
--color-headplane-100: #e6e6e6;
--color-headplane-200: #cccccc;
--color-headplane-300: #b3b3b3;
--color-headplane-400: #999999;
--color-headplane-500: #808080;
--color-headplane-600: #666666;
--color-headplane-700: #4d4d4d;
--color-headplane-800: #343434;
--color-headplane-900: #1a1a1a;
--color-headplane-950: #0d0d0d;
--animate-loading: loader 0.8s infinite ease-in-out;
--animate-loading: loader 0.8s infinite ease-in-out;
@keyframes loader {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
@keyframes loader {
from {
transform: translateX(-100%);
}
to {
transform: translateX(100%);
}
}
}
@utility container {
margin-inline: auto;
padding-inline: 1rem;
margin-inline: auto;
padding-inline: 1rem;
width: 100%;
max-width: 96rem; /* 1536px - constrain on large displays */
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
@media (width >= theme(--breakpoint-sm)) {
padding-inline: 2rem;
}
@media (width >= theme(--breakpoint-sm)) {
padding-inline: 2rem;
}
@media (width >= theme(--breakpoint-lg)) {
padding-inline: 4rem;
}
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
@media (width >= theme(--breakpoint-lg)) {
padding-inline: 4rem;
}
@media (width >= theme(--breakpoint-xl)) {
padding-inline: 5rem;
}
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
@media (width >= theme(--breakpoint-xl)) {
padding-inline: 5rem;
}
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
@media (width >= theme(--breakpoint-2xl)) {
padding-inline: 6rem;
}
@media (width >= theme(--breakpoint-2xl)) {
padding-inline: 6rem;
}
}
@supports (scrollbar-gutter: stable) {
html {
scrollbar-gutter: stable;
}
html {
scrollbar-gutter: stable;
}
}
.cm-merge-theme {
height: 100% !important;
height: 100% !important;
}
.cm-mergeView {
height: 100% !important;
height: 100% !important;
}
.cm-mergeViewEditors {
height: 100%;
height: 100%;
}
.cm-mergeViewEditor {
height: 100% !important;
height: 100% !important;
}
/* Weirdest class name characters but ok */
.cm-mergeView .ͼ1 .cm-scroller,
.cm-mergeView .ͼ1 {
height: 100% !important;
height: 100% !important;
}
+25 -27
View File
@@ -1,35 +1,33 @@
import type { PreAuthKey } from './PreAuthKey';
import type { User } from './User';
import type { PreAuthKey } from "./PreAuthKey";
import type { User } from "./User";
export interface Machine {
id: string;
machineKey: string;
nodeKey: string;
discoKey: string;
ipAddresses: string[];
name: string;
id: string;
machineKey: string;
nodeKey: string;
discoKey: string;
ipAddresses: string[];
name: string;
user: User;
lastSeen: string;
expiry: string | null;
user: User;
lastSeen: string;
expiry: string | null;
preAuthKey?: PreAuthKey;
preAuthKey?: PreAuthKey;
createdAt: string;
registerMethod:
| 'REGISTER_METHOD_UNSPECIFIED'
| 'REGISTER_METHOD_AUTH_KEY'
| 'REGISTER_METHOD_CLI'
| 'REGISTER_METHOD_OIDC';
createdAt: string;
registerMethod:
| "REGISTER_METHOD_UNSPECIFIED"
| "REGISTER_METHOD_AUTH_KEY"
| "REGISTER_METHOD_CLI"
| "REGISTER_METHOD_OIDC";
forcedTags: string[];
invalidTags: string[];
validTags: string[];
givenName: string;
online: boolean;
tags: string[];
givenName: string;
online: boolean;
// Added in Headscale 0.26+
approvedRoutes: string[];
availableRoutes: string[];
subnetRoutes: string[];
// Added in Headscale 0.26+
approvedRoutes: string[];
availableRoutes: string[];
subnetRoutes: string[];
}
+39 -54
View File
@@ -1,65 +1,50 @@
import { HostInfo, Machine } from '~/types';
import { HostInfo, Machine } from "~/types";
export interface PopulatedNode extends Machine {
routes: string[];
hostInfo?: HostInfo;
expired: boolean;
customRouting: {
exitRoutes: string[];
exitApproved: boolean;
subnetApprovedRoutes: string[];
subnetWaitingRoutes: string[];
};
routes: string[];
hostInfo?: HostInfo;
expired: boolean;
customRouting: {
exitRoutes: string[];
exitApproved: boolean;
subnetApprovedRoutes: string[];
subnetWaitingRoutes: string[];
};
}
export function mapNodes(
nodes: Machine[],
stats?: Record<string, HostInfo> | undefined,
nodes: Machine[],
stats?: Record<string, HostInfo> | undefined,
): PopulatedNode[] {
return nodes.map((node) => {
const customRouting = {
exitRoutes: node.availableRoutes.filter(
(route) => route === '::/0' || route === '0.0.0.0/0',
),
exitApproved: node.approvedRoutes.some(
(route) => route === '::/0' || route === '0.0.0.0/0',
),
subnetApprovedRoutes: node.approvedRoutes.filter(
(route) =>
route !== '::/0' &&
route !== '0.0.0.0/0' &&
node.availableRoutes.includes(route),
),
subnetWaitingRoutes: node.availableRoutes.filter(
(route) =>
route !== '::/0' &&
route !== '0.0.0.0/0' &&
!node.approvedRoutes.includes(route),
),
} satisfies PopulatedNode['customRouting'];
return nodes.map((node) => {
const customRouting = {
exitRoutes: node.availableRoutes.filter((route) => route === "::/0" || route === "0.0.0.0/0"),
exitApproved: node.approvedRoutes.some((route) => route === "::/0" || route === "0.0.0.0/0"),
subnetApprovedRoutes: node.approvedRoutes.filter(
(route) =>
route !== "::/0" && route !== "0.0.0.0/0" && node.availableRoutes.includes(route),
),
subnetWaitingRoutes: node.availableRoutes.filter(
(route) =>
route !== "::/0" && route !== "0.0.0.0/0" && !node.approvedRoutes.includes(route),
),
} satisfies PopulatedNode["customRouting"];
return {
...node,
routes: Array.from(new Set(node.availableRoutes)),
hostInfo: stats?.[node.nodeKey],
customRouting,
expired:
node.expiry === '0001-01-01 00:00:00' ||
node.expiry === '0001-01-01T00:00:00Z' ||
node.expiry === null
? false
: new Date(node.expiry).getTime() < Date.now(),
};
});
return {
...node,
routes: Array.from(new Set(node.availableRoutes)),
hostInfo: stats?.[node.nodeKey],
customRouting,
expired:
node.expiry === "0001-01-01 00:00:00" ||
node.expiry === "0001-01-01T00:00:00Z" ||
node.expiry === null
? false
: new Date(node.expiry).getTime() < Date.now(),
};
});
}
export function sortNodeTags(nodes: Machine[]): string[] {
return Array.from(
new Set(
nodes.flatMap(({ validTags, forcedTags }) => [
...validTags,
...forcedTags,
]),
),
).sort();
return Array.from(new Set(nodes.flatMap((node) => node.tags))).sort();
}
+48
View File
@@ -0,0 +1,48 @@
import { createCookie } from "react-router";
import type { HeadplaneConfig } from "~/server/config/config-schema";
export interface OidcStateCookie {
nonce: string;
state: string;
verifier: string;
redirect_uri: string;
}
export function createOidcStateCookie(config: HeadplaneConfig) {
const cookie = createCookie("__oidc_state", {
httpOnly: true,
maxAge: 1800,
secure: config.server.cookie_secure,
domain: config.server.cookie_domain,
path: `${__PREFIX__}/oidc/callback`,
});
return {
...cookie,
serialize: async (value: OidcStateCookie): Promise<string> => {
return cookie.serialize(value);
},
parse: async (cookieHeader: string | null): Promise<OidcStateCookie | null> => {
const parsed = await cookie.parse(cookieHeader);
if (
parsed == null ||
typeof parsed !== "object" ||
typeof parsed.nonce !== "string" ||
typeof parsed.state !== "string" ||
typeof parsed.verifier !== "string" ||
typeof parsed.redirect_uri !== "string"
) {
return null;
}
return {
nonce: parsed.nonce,
state: parsed.state,
verifier: parsed.verifier,
redirect_uri: parsed.redirect_uri,
};
},
};
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Formats the time delta since a given date into a human-readable string.
* - Under 1 hour: "X minutes ago"
* - Under 1 day: "X hours, Y minutes ago"
* - Under 1 month: "X days, Y hours ago"
* - Over 1 month: "X months, Y days ago"
*/
export function formatTimeDelta(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const minutes = Math.floor(diffMs / (1000 * 60));
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const months = Math.floor(days / 30);
if (minutes < 60) {
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
}
if (hours < 24) {
const remainingMinutes = minutes % 60;
if (remainingMinutes === 0) {
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
}
return `${hours} hour${hours !== 1 ? 's' : ''}, ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''} ago`;
}
if (days < 30) {
const remainingHours = hours % 24;
if (remainingHours === 0) {
return `${days} day${days !== 1 ? 's' : ''} ago`;
}
return `${days} day${days !== 1 ? 's' : ''}, ${remainingHours} hour${remainingHours !== 1 ? 's' : ''} ago`;
}
const remainingDays = days % 30;
if (remainingDays === 0) {
return `${months} month${months !== 1 ? 's' : ''} ago`;
}
return `${months} month${months !== 1 ? 's' : ''}, ${remainingDays} day${remainingDays !== 1 ? 's' : ''} ago`;
}
-48
View File
@@ -1,48 +0,0 @@
{
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": true
},
"files": {
"ignoreUnknown": false,
"includes": ["**", "!app/wasm_exec.js"]
},
"formatter": {
"enabled": true,
"indentStyle": "tab",
"lineWidth": 80,
"lineEnding": "lf"
},
"assist": {
"actions": {
"source": {
"organizeImports": "on",
"useSortedAttributes": "on"
}
}
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"style": {
"useImportType": "off",
"noNonNullAssertion": "off"
},
"correctness": {
"useExhaustiveDependencies": "off"
},
"suspicious": {
"noExplicitAny": "warn"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"jsxQuoteStyle": "double"
}
}
}
+20 -1
View File
@@ -16,6 +16,7 @@ BUILD_WASM=0
BUILD_APP=0
BUILD_AGENT=0
BUILD_FAKE_SHELL=0
BUILD_HEALTHCHECK=0
SKIP_PATH_CHECKS=0
SKIP_PNPM_PRUNE=0
APP_INSTALL_ONLY=0
@@ -23,6 +24,7 @@ APP_INSTALL_ONLY=0
WASM_OUTPUT="$PUBLIC_DIR/hp_ssh.wasm"
AGENT_OUTPUT="$BUILD_DIR/hp_agent"
FAKE_SHELL_OUTPUT="$BUILD_DIR/hp_fake_sh"
HEALTHCHECK_OUTPUT="$BUILD_DIR/hp_healthcheck"
die() { echo "error: $*" >&2; exit 1; }
run() { echo ">> $*"; "$@"; }
@@ -33,6 +35,7 @@ while [ $# -gt 0 ]; do
--app) BUILD_APP=1 ;;
--agent) BUILD_AGENT=1 ;;
--fake-shell) BUILD_FAKE_SHELL=1 ;;
--healthcheck) BUILD_HEALTHCHECK=1 ;;
--skip-path-checks) SKIP_PATH_CHECKS=1 ;;
--skip-pnpm-prune) SKIP_PNPM_PRUNE=1 ;;
--app-install-only) APP_INSTALL_ONLY=1 ;;
@@ -55,6 +58,12 @@ while [ $# -gt 0 ]; do
FAKE_SHELL_OUTPUT=$1
;;
--healthcheck-output)
shift
[ $# -gt 0 ] || die "--healthcheck-output requires a path"
HEALTHCHECK_OUTPUT=$1
;;
--help)
cat <<EOF
Usage: $0 [flags]
@@ -62,12 +71,14 @@ Usage: $0 [flags]
--app build react-router app
--agent build tailscale agent
--fake-shell build fake shell binary (for Docker)
--healthcheck build healthcheck binary
--skip-path-checks skip safety checks (ie. checking PATH)
--skip-pnpm-prune skip pruning devDependencies from node_modules
--app-install-only only install app dependencies, skip build
--wasm-output <path> override wasm output path
--agent-output <path> override agent output path
--fake-shell-output <path> override fake shell output path
--healthcheck-output <path> override healthcheck output path
EOF
exit 0
;;
@@ -84,6 +95,8 @@ if [ "$BUILD_WASM" -eq 0 ] && [ "$BUILD_APP" -eq 0 ] && \
BUILD_WASM=1
BUILD_APP=1
BUILD_AGENT=1
BUILD_HEALTHCHECK=1
BUILD_FAKE_SHELL=0
fi
if [ "$SKIP_PATH_CHECKS" -eq 0 ]; then
@@ -96,6 +109,7 @@ if [ "$SKIP_PATH_CHECKS" -eq 0 ]; then
[ "$BUILD_APP" -eq 1 ] && need_pnpm=1
[ "$BUILD_AGENT" -eq 1 ] && need_go=1
[ "$BUILD_FAKE_SHELL" -eq 1 ] && need_go=1
[ "$BUILD_HEALTHCHECK" -eq 1 ] && need_go=1
if [ $need_go -eq 1 ]; then
echo "==> Checking for Go toolchain"
@@ -160,11 +174,16 @@ build_fake_shell() {
-o "$FAKE_SHELL_OUTPUT" ./cmd/fake_sh
}
[ "$BUILD_FAKE_SHELL" = 1 ] && build_fake_shell
build_healthcheck() {
echo "==> Building healthcheck binary → $HEALTHCHECK_OUTPUT"
mkdir -p "$(dirname "$HEALTHCHECK_OUTPUT")"
go build -o "$HEALTHCHECK_OUTPUT" ./cmd/hp_healthcheck
}
[ "$BUILD_WASM" = 1 ] && build_wasm
[ "$BUILD_APP" = 1 ] && build_app
[ "$BUILD_AGENT" = 1 ] && build_agent
[ "$BUILD_FAKE_SHELL" = 1 ] && build_fake_shell
[ "$BUILD_HEALTHCHECK" = 1 ] && build_healthcheck
echo "✅ Build complete."
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"fmt"
"net/http"
"os"
"time"
)
func main() {
client := http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://localhost:3000/admin/healthz")
if err != nil {
fmt.Fprintf(os.Stderr, "Health check failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "Health check returned non-OK status: %d\n", resp.StatusCode)
os.Exit(1)
}
fmt.Println("Health check passed.")
os.Exit(0)
}
+1 -1
View File
@@ -24,7 +24,7 @@ services:
- "./test/caddy/config:/config"
- "./test/caddy/certs:/certs"
headscale:
image: "headscale/headscale:0.27.0-debug"
image: "headscale/headscale:0.28.0"
container_name: "headscale"
labels:
me.tale.headplane.target: headscale
+29 -10
View File
@@ -4,6 +4,11 @@ server:
host: "0.0.0.0"
port: 3000
# The base URL for Headplane. Please keep in mind that this will be required
# for Headscale to properly function going forward AND it should not include
# the dashboard prefix (/admin) portion.
base_url: "http://localhost:3000"
# The secret used to encode and decode web sessions (must be 32 characters)
# You may also provide `cookie_secret_path` instead to read a value from disk.
# See https://headplane.net/configuration/#sensitive-values
@@ -30,6 +35,16 @@ server:
# PLEASE ensure this directory is mounted if running in Docker.
data_path: "/var/lib/headplane"
# The info secret is optional and allows access to certain debug endpoints
# that may expose sensitive information about your Headplane instance.
#
# As of now, this protects the /api/info endpoint which exposes details about
# the Headplane and Headscale versions in use. In the future, more endpoints
# may be protected by this secret.
#
# If not set, these endpoints will be disabled.
# info_secret: "<change_me_to_something_secure!>"
# Headscale specific settings to allow Headplane to talk
# to Headscale and access deep integration features
headscale:
@@ -149,9 +164,9 @@ integration:
proc:
enabled: false
# OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience)
# oidc:
# OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience)
# oidc:
# The OIDC issuer URL
# issuer: "https://accounts.google.com"
@@ -170,6 +185,11 @@ integration:
# token_endpoint: ""
# userinfo_endpoint: ""
# The authentication method to use when communicating with the token endpoint.
# This is fully optional and Headplane will attempt to auto-detect the best
# method and fall back to `client_secret_basic` if unsure.
# token_endpoint_auth_method: "client_secret_post"
# The client ID for the OIDC client
# For the best experience please ensure this is *identical* to the client_id
# you are using for Headscale. because
@@ -180,16 +200,15 @@ integration:
# See https://headplane.net/configuration/#sensitive-values
# client_secret: "<your-client-secret>"
# Whether to use PKCE when authenticating users. This is recommended as it
# adds an extra layer of security to the authentication process. Enabling this
# means your OIDC provider must support PKCE and it must be enabled on the
# client.
# use_pkce: true
# If you want to disable traditional login via Headscale API keys
# disable_api_key_login: false
# Optional, but highly recommended otherwise Headplane
# will attempt to automatically guess this from the issuer
#
# This should point to your publicly accessibly URL
# for your Headplane instance with /admin/oidc/callback
# redirect_uri: "http://localhost:3000/admin/oidc/callback"
# By default profile pictures are pulled from the OIDC provider when
# we go to fetch the userinfo endpoint. Optionally, this can be set to
# "oidc" or "gravatar" as of 0.6.1.
+7
View File
@@ -11,6 +11,9 @@ export default defineConfig({
{ text: 'Home', link: '/' },
{ text: 'Changelog', link: '/CHANGELOG' },
],
search: {
provider: 'local',
},
sidebar: [
{
text: 'Getting Started',
@@ -30,6 +33,10 @@ export default defineConfig({
link: '/configuration',
items: [
{ text: 'Common Issues', link: '/configuration/common-issues' },
{
text: 'Sensitive Values',
link: '/configuration#sensitive-values',
},
],
},
{ text: 'Nix', link: '/Nix' },
+24 -20
View File
@@ -6,39 +6,42 @@ sole maintainer, I have a few guidelines which help make it significantly easier
for me to review and merge your contributions.
## Contribution Types
- **Bug Reports/Feature Requests**: If you find a bug, please open an
[issue](https://github.com/tale/headplane/issues) using one of the predefined
templates. Issues are only for bug reports and feature requests, not problems
that you encounter from misconfiguration or usage. Those belong in the
[discussions](https://github.com/tale/headplane/discussions) section.
[issue](https://github.com/tale/headplane/issues) using one of the predefined
templates. Issues are only for bug reports and feature requests, not problems
that you encounter from misconfiguration or usage. Those belong in the
[discussions](https://github.com/tale/headplane/discussions) section.
- **Documentation/Examples**: If you find any issues in the documentation or
would like to contribute examples for setting up Headplane, please open a PR
and I will review it and possibly make changes.
would like to contribute examples for setting up Headplane, please open a PR
and I will review it and possibly make changes.
- **Code Contributions**: Code contributions are done via PRs but *must* be
linked to an issue or a feature request (you can make one if it doesn't exist).
Please tag them with the appropriate labels when opening an issue or PR.
- **Code Contributions**: Code contributions are done via PRs but _must_ be
linked to an issue or a feature request (you can make one if it doesn't exist).
Please tag them with the appropriate labels when opening an issue or PR.
### Code Contribution Restrictions
- **No Large Refactors**: I am not interested in large refactors of the codebase
because I've already had do to this a lot and it becomes a headache to review,
maintain, and merge. This also means you should split up multiple contributions
into multiple PRs if they are unrelated to each other.
because I've already had do to this a lot and it becomes a headache to review,
maintain, and merge. This also means you should split up multiple contributions
into multiple PRs if they are unrelated to each other.
- **No Project/Tooling Changes**: Unless there is a very good reason to do so,
I will not accept changes to the project structure, build system, or tooling
used to develop Headplane. This includes things like changing the package
manager, docker environment, or CI/CD.
I will not accept changes to the project structure, build system, or tooling
used to develop Headplane. This includes things like changing the package
manager, docker environment, or CI/CD.
- **Minimal Breaking Changes**: I will not accept any changes that break any
existing functionality or change the API unless there is a very good reason
to do so. If you want to make a breaking change, please open an issue first
and discuss it with me.
existing functionality or change the API unless there is a very good reason
to do so. If you want to make a breaking change, please open an issue first
and discuss it with me.
### Code Style
This is very easy and self-explanatory. [Biome](https://biomejs.dev) is used as
a linter and formatter for the TypeScript, while Go's default formatting and
This is very easy and self-explanatory. [oxlint/oxfmt](https://oxc.rs) is used
as a linter and formatter for the TypeScript, while Go's default formatting and
lint tooling are used within `agent/` code. I've setup a git hook to run
before commit to make these changes automatically.
@@ -47,6 +50,7 @@ before commit to make these changes automatically.
> but instead we can discuss them and see if we can come to a compromise.
### Contribution Tooling
If you plan to work with the WASM SSH agent, you will need to install
`mkcert` or an equivalent to create certificates in `./test/caddy/certs`.
It expects a `localhost.pem` and `localhost-key.pem` file to be present in that
+111 -1
View File
@@ -1,6 +1,7 @@
---
title: Single Sign-On (SSO)
description: Configure Single Sign-On (SSO) authentication for Headplane.
outline: [2, 3]
---
# Single Sign-On (SSO)
@@ -11,4 +12,113 @@ description: Configure Single Sign-On (SSO) authentication for Headplane.
<figcaption>SSO Configuration Page</figcaption>
</figure>
TODO
Single Sign-On allows users to authenticate with Headplane through an external
Identity Provider (IdP). It does this using the OpenID Connect (OIDC) protocol,
which is widely supported by many popular IdPs.
## Getting Started
To set up Single Sign-On (SSO) with Headplane, there are several steps involved.
As a general recommendation, please read through the entire guide before
beginning the process as there are several important factors to consider.
### Requirements
::: warning
If you are also using OpenID Connect (OIDC) authentication with Headscale, it is
**fundamentally important** that both Headscale and Headplane are configured to
use the *exact same client* in your Identity Provider (IdP). This means that
both services should share the same client ID and secret.
This is necessary because Headplane relies on the user IDs provided by the IdP
to match users with their equivalent Headscale users. If Headscale and Headplane
are using different clients, the user IDs may not match up correctly, preventing
a user from viewing their devices in Headplane.
:::
You'll need the following things set up before proceeding:
- A working Headplane installation that is already configured.
- An Identity Provider (IdP) that supports OAuth2 and OpenID Connect (OIDC).
- `server.base_url` set to the public URL of your Headplane instance in your
configuration file (ie. the domain that's visible in the browser).
- A Headscale API key with a relatively long expiration time (eg. 1 year).
### Configuring the Client
You'll need to create a client in your Identity Provider (IdP) that Headplane
can use for authentication. A part of that step involves giving an allowed
"redirect URL" to your IdP. This URL is where the IdP will send users back to
after they have authenticated.
For Headplane, the redirect URL will be in the following format, where the
domain is replaced with the value set for `server.base_url` in your Headplane
configuration:
```
https://headplane.example.com/admin/oidc/callback
```
Once you have created the client in your IdP, make note of the following
information as you'll need it for the Headplane configuration:
- Client ID
- Client Secret (if applicable)
- Issuer URL
### OIDC Configuration
To enable OIDC authentication in Headplane, you'll need to add the necessary
configuration options via the file or environment variables. See below:
```yaml
oidc:
headscale_api_key: "<generated-api-key>"
issuer: "https://your-idp.com"
client_id: "your-client-id"
client_secret: "your-client-secret"
# You can also provide the client secret via a file:
# client_secret_path: "${HOME}/secrets/headplane_oidc_client_secret.txt"
# Those options should generally be sufficient, but you can also set these:
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# scope: "openid email profile"
# extra_params:
# foo: "bar"
# baz: "qux"
```
Headplane automatically tries to discover the necessary OIDC endpoints but if
your IdP does not support discovery, you may need to manually specify them.
### PKCE
::: warning
Headplane currently only supports the **`S256`** code challenge method for PKCE.
You may need to ensure that your Identity Provider is configured to accept this
method.
:::
By default, Headplane does not use PKCE (Proof Key for Code Exchange) when
communicating with the Identity Provider. PKCE is generally a best practice for
OIDC and can enhance security. *Some Identity Providers may even require PKCE
to be used.* To enable PKCE you'll need to set `oidc.use_pkce`
to `true` in your Headplane configuration file:
```yaml
oidc:
use_pkce: true
```
## Troubleshooting
Some of the common issues you may encounter when configuring OIDC with Headplane
include:
- **Invalid API Key**: Ensure that the API key provided to Headplane is valid
and has not expired.
- **Missing [some]_endpoint**: If your IdP does not provide standard OIDC
endpoints, you may need to manually specify them in the Headplane configuration.
- **Missing the `sub` claim**: Ensure that your IdP is configured to include the
`sub` claim in the ID token, as this is required for Headplane to identify users.
- **Redirect URI Mismatch**: Ensure that the redirect URI configured in your IdP
and that `server.base_url` in Headplane match exactly.
- **Cookie Issues**: The OIDC authentication relies on your cookie configuration
for Headplane. If OIDC cannot complete due to a missing session or invalid
session then please check your cookie settings.
+22
View File
@@ -39,6 +39,28 @@ It's important to mount your configuration file and also provide a persistent
storage location for Headplane to store its own data. You can also change the
port mapping if you want to run it on a different port.
## Health Checks
The Docker image includes a built-in healthcheck that verifies the Headplane
server is running and responding. Docker will automatically monitor the
container and report its health status. No additional configuration is required.
The healthcheck binary is located at `/bin/hp_healthcheck` inside the container.
If you need to override the default healthcheck behavior, you can do so in your
`compose.yaml`:
```yaml
services:
headplane:
image: ghcr.io/tale/headplane:latest
healthcheck:
test: ["/bin/hp_healthcheck"]
interval: 30s
timeout: 5s
start_period: 5s
retries: 3
```
## Accessing Headplane
After starting the container, you can access the Headplane web interface by
+15 -11
View File
@@ -5,6 +5,7 @@ outline: [2, 3]
---
# Native Mode
::: tip
If you are looking to deploy with Docker, follow the
[**Docker**](./docker.md) deployment guide.
@@ -15,11 +16,12 @@ for Docker. This method is suitable for users who already run Headscale natively
or prefer to avoid containers.
## Prerequisites
- A Linux-based operating system (e.g, Ubuntu, Debian, CentOS, Fedora)
- Go version 1.25.1 installed (only needed to build Headplane)
- Node.js version 22.16.x and [pnpm](https://pnpm.io/) version 10.4.x installed
- Headscale version 0.26.0 or later installed and running
- A [completed configuration file](./index.md#configuration) for Headplane.
- A [completed configuration file](./index.md#configuration) for Headplane.
Before building and running Headplane, ensure that the directory defined in
`server.data_path` in your configuration exists and is writable by the user who
@@ -32,6 +34,7 @@ sudo chown -R $(whoami):$(whoami) /var/lib/headplane
```
## Building Headplane
Clone the Headplane repository, install dependencies, and build the project:
```bash
@@ -43,6 +46,7 @@ pnpm build
```
## Running Headplane
Running Headplane is as straightforward as running `pnpm start` (or also
directly with `node build/server/index.js`). Headplane will look for a config
file at `/etc/headplane/config.yaml` by default, but you can specify a different
@@ -81,7 +85,7 @@ WantedBy=multi-user.target
To access Headplane, navigate to `http://localhost:3000/admin` in your web
browser (replace `localhost` with your server's IP address or domain name if
not running locally).
not running locally).
In order to log in, you'll need to supply a Headscale API key. You can create
one by running the following command within your Headscale environment:
@@ -103,6 +107,7 @@ Network management allows you to configure Tailnet settings such as DNS servers,
custom A records, the tailnet domain name, and MagicDNS from the Headplane UI.
#### Prerequisites
Network management (and other configurable Headscale features) requires that
Headplane and Headscale both run on the same machine because Headplane needs
@@ -114,11 +119,11 @@ Headplane and Headscale both run on the same machine because Headplane needs
Enabling network management is as simple as setting a few additional fields in
your Headplane configuration file:
| Field | Description |
|---------------------|--------------------------------------------------------|
| **`integration.proc.enabled`** | Set to `true` to enable process inspection. |
| **`headscale.config_path`** | Path to your Head**scale** configuration file (e.g., `/etc/headscale/config.yaml`). |
| `headscale.dns_records_path` | *Optional*. Refer to the [example configuration](https://github.com/tale/headplane/blob/main/config.example.yaml) for details. |
| Field | Description |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------ |
| **`integration.proc.enabled`** | Set to `true` to enable process inspection. |
| **`headscale.config_path`** | Path to your Head**scale** configuration file (e.g., `/etc/headscale/config.yaml`). |
| `headscale.dns_records_path` | _Optional_. Refer to the [example configuration](https://github.com/tale/headplane/blob/main/config.example.yaml) for details. |
With these settings in place, restart Headplane. You should now see additional
options in the UI navbar such as "DNS" and "Settings" where you can manage your
@@ -138,7 +143,6 @@ setup instructions and specifically follow the
[native mode configuration](../features/agent.md#native-mode-configuration)
section to point Headplane to the correct agent location.
### Single Sign-On (SSO)
Single Sign-On (SSO) authentication allows users to log in to Headplane using
@@ -148,14 +152,14 @@ supports OpenID Connect (OIDC).
To get started with SSO, refer to the [SSO documentation](../features/sso.md)
for detailed setup instructions.
## Reverse Proxying
You *should* run Headplane behind a reverse proxy such as Nginx or Caddy in
You _should_ run Headplane behind a reverse proxy such as Nginx or Caddy in
production. Additionally, putting Headscale beind the reverse proxy allows
you to access both services via the same domain and TLS certificate.
#### Configuration
::: tip
If you are using a [custom path prefix](#custom-path-prefix) for Headplane,
adjust the `/admin` paths in the examples below accordingly.
@@ -205,7 +209,7 @@ server {
}
location /admin/ { # Headplane is served under /admin
proxy_pass http://localhost:3000/; # Adjust if Headplane runs on a different port
proxy_pass http://localhost:3000; # Adjust if Headplane runs on a different port
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
Generated
+6 -6
View File
@@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1762521437,
"narHash": "sha256-RXN+lcx4DEn3ZS+LqEJSUu/HH+dwGvy0syN7hTo/Chg=",
"lastModified": 1768818222,
"narHash": "sha256-460jc0+CZfyaO8+w8JNtlClB2n4ui1RbHfPTLkpwhU8=",
"owner": "numtide",
"repo": "devshell",
"rev": "07bacc9531f5f4df6657c0a02a806443685f384a",
"rev": "255a2b1725a20d060f566e4755dbf571bbbb5f76",
"type": "github"
},
"original": {
@@ -40,11 +40,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1763191728,
"narHash": "sha256-esRhOS0APE6k40Hs/jjReXg+rx+J5LkWw7cuWFKlwYA=",
"lastModified": 1770380644,
"narHash": "sha256-P7dWMHRUWG5m4G+06jDyThXO7kwSk46C1kgjEWcybkE=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "1d4c88323ac36805d09657d13a5273aea1b34f0c",
"rev": "ae67888ff7ef9dff69b3cf0cc0fbfbcd3a722abe",
"type": "github"
},
"original": {
+6 -2
View File
@@ -1,8 +1,12 @@
pre-commit:
commands:
check:
lint:
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}"
run: pnpm biome check --write --no-errors-on-unmatched --files-ignore-unknown=true --colors=off {staged_files}
run: pnpm lint --fix {staged_files}
stage_fixed: true
format:
glob: "*"
run: pnpm format --no-error-on-unmatched-pattern {staged_files}
stage_fixed: true
check_go:
glob: "*.go"
+1 -1
View File
@@ -28,7 +28,7 @@ in
pnpmDeps = pnpm_10.fetchDeps {
inherit (finalAttrs) pname version src;
hash = "sha256-DhmVMQZT53wqi2C3+Tn07KmKrghq48yrCPh/eUMj5tQ=";
hash = "sha256-z51OBxoJ5SrC8ctfk4z+tUtiSQzBq4iiJlngfK60cqo=";
fetcherVersion = 1;
};
+120 -117
View File
@@ -1,119 +1,122 @@
{
"name": "headplane",
"private": true,
"sideEffects": false,
"version": "0.6.1",
"type": "module",
"scripts": {
"preinstall": "npx only-allow pnpm",
"build": "react-router build",
"dev": "HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev",
"start": "node build/server/index.js",
"typecheck": "react-router typegen && tsgo",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"gen:hashes": "tsx tests/generate-openapi-hashes.ts"
},
"dependencies": {
"@libsql/client": "0.15.15"
},
"devDependencies": {
"@biomejs/biome": "^2.3.5",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@faker-js/faker": "9.9.0",
"@fontsource-variable/inter": "^5.2.8",
"@iconify/react": "^6.0.2",
"@kubernetes/client-node": "^1.4.0",
"@react-aria/toast": "3.0.8",
"@react-router/dev": "^7.9.6",
"@react-router/node": "^7.9.6",
"@react-stately/toast": "3.1.2",
"@readme/openapi-parser": "^5.2.1",
"@shopify/lang-jsonc": "^1.0.1",
"@tailwindcss/vite": "^4.1.17",
"@types/js-yaml": "^4.0.9",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20251203.1",
"@uiw/codemirror-theme-github": "4.25.1",
"@uiw/codemirror-theme-xcode": "4.25.3",
"@uiw/react-codemirror": "4.25.3",
"@xterm/addon-clipboard": "^0.1.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/addon-unicode11": "^0.8.0",
"@xterm/addon-web-links": "^0.11.0",
"@xterm/xterm": "^5.5.0",
"arktype": "^2.1.26",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.7",
"drizzle-orm": "0.44.7",
"isbot": "5.1.32",
"jose": "6.1.2",
"js-yaml": "^4.1.1",
"lefthook": "^2.0.4",
"lucide-react": "^0.540.0",
"mime": "^4.1.0",
"openapi-types": "^12.1.3",
"openid-client": "6.8.1",
"postcss": "^8.5.6",
"react": "19.2.0",
"react-aria": "3.44.0",
"react-codemirror-merge": "4.25.3",
"react-dom": "19.2.0",
"react-error-boundary": "^6.0.0",
"react-router": "^7.9.6",
"react-router-dom": "^7.9.6",
"react-router-hono-server": "2.22.0",
"react-stately": "3.42.0",
"remix-utils": "^9.0.0",
"tailwind-merge": "3.4.0",
"tailwindcss": "^4.1.17",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-react-aria-components": "^2.0.1",
"testcontainers": "^11.8.1",
"tsx": "^4.20.6",
"typescript": "^5.9.3",
"ulidx": "2.4.1",
"undici": "7.16.0",
"usehooks-ts": "^3.1.1",
"vite": "8.0.0-beta.0",
"vite-tsconfig-paths": "^5.1.4",
"vitepress": "next",
"vitest": "^4.0.9",
"yaml": "2.8.1"
},
"packageManager": "pnpm@10.4.0",
"engines": {
"node": ">=22.18 <23",
"pnpm": ">=10.4 <11"
},
"pnpm": {
"supportedArchitectures": {
"os": [
"current",
"linux"
],
"cpu": [
"x64",
"arm64"
]
},
"onlyBuiltDependencies": [
"@biomejs/biome",
"@tailwindcss/oxide",
"better-sqlite3",
"esbuild",
"lefthook"
],
"patchedDependencies": {
"react-router-hono-server": "patches/react-router-hono-server.patch"
}
}
"name": "headplane",
"version": "0.6.1",
"private": true,
"type": "module",
"sideEffects": false,
"scripts": {
"preinstall": "npx only-allow pnpm",
"build": "react-router build",
"dev": "HEADPLANE_CONFIG_PATH=./config.example.yaml react-router dev",
"start": "node build/server/index.js",
"typecheck": "react-router typegen && tsgo",
"test:unit": "vitest run --project unit",
"test:integration": "vitest run --project integration",
"docs:dev": "vitepress dev docs",
"docs:build": "vitepress build docs",
"docs:preview": "vitepress preview docs",
"gen:hashes": "tsx tests/generate-openapi-hashes.ts",
"lint": "oxlint",
"format": "oxfmt"
},
"dependencies": {
"@libsql/client": "0.17.0"
},
"devDependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@faker-js/faker": "10.2.0",
"@fontsource-variable/inter": "^5.2.8",
"@iconify/react": "^6.0.2",
"@kubernetes/client-node": "^1.4.0",
"@react-aria/toast": "3.0.9",
"@react-router/dev": "^7.12.0",
"@react-router/node": "^7.12.0",
"@react-stately/toast": "3.1.2",
"@readme/openapi-parser": "^5.5.0",
"@shopify/lang-jsonc": "^1.0.1",
"@tailwindcss/vite": "^4.1.18",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.0.9",
"@types/react": "^19.2.8",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260115.1",
"@uiw/codemirror-theme-github": "4.25.1",
"@uiw/codemirror-theme-xcode": "4.25.4",
"@uiw/react-codemirror": "4.25.4",
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"arktype": "^2.1.29",
"clsx": "^2.1.1",
"drizzle-kit": "^0.31.8",
"drizzle-orm": "0.45.1",
"isbot": "5.1.32",
"jose": "6.1.3",
"js-yaml": "^4.1.1",
"lefthook": "^2.0.15",
"lucide-react": "^0.562.0",
"mime": "^4.1.0",
"openapi-types": "^12.1.3",
"openid-client": "6.8.1",
"oxfmt": "^0.24.0",
"oxlint": "^1.39.0",
"oxlint-tsgolint": "^0.11.1",
"postcss": "^8.5.6",
"react": "19.2.3",
"react-aria": "3.45.0",
"react-codemirror-merge": "4.25.4",
"react-dom": "19.2.3",
"react-error-boundary": "^6.1.0",
"react-router": "^7.12.0",
"react-router-dom": "^7.12.0",
"react-router-hono-server": "2.23.0",
"react-stately": "3.43.0",
"remix-utils": "^9.0.0",
"tailwind-merge": "3.4.0",
"tailwindcss": "^4.1.18",
"tailwindcss-animate": "^1.0.7",
"tailwindcss-react-aria-components": "^2.0.1",
"testcontainers": "^11.11.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"ulidx": "2.4.1",
"undici": "7.18.2",
"usehooks-ts": "^3.1.1",
"vite": "8.0.0-beta.0",
"vite-tsconfig-paths": "^6.0.4",
"vitepress": "next",
"vitest": "^4.0.17",
"yaml": "2.8.2"
},
"engines": {
"node": ">=22.18 <23",
"pnpm": ">=10.4 <11"
},
"packageManager": "pnpm@10.4.0",
"pnpm": {
"supportedArchitectures": {
"os": [
"current",
"linux"
],
"cpu": [
"x64",
"arm64"
]
},
"onlyBuiltDependencies": [
"@tailwindcss/oxide",
"better-sqlite3",
"esbuild",
"lefthook"
],
"patchedDependencies": {
"react-router-hono-server": "patches/react-router-hono-server.patch"
}
}
}
+2694 -2227
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,10 +1,10 @@
import type { Config } from '@react-router/dev/config';
import type { Config } from "@react-router/dev/config";
export default {
basename: '/admin/',
ssr: true,
future: {
unstable_optimizeDeps: true,
unstable_splitRouteModules: 'enforce',
},
basename: "/admin/",
ssr: true,
future: {
unstable_optimizeDeps: true,
v8_splitRouteModules: "enforce",
},
} satisfies Config;
+69 -66
View File
@@ -1,98 +1,101 @@
import { writeFile } from 'node:fs/promises';
import { resolve } from 'node:path';
import { cwd } from 'node:process';
import type { OpenAPIV2 } from 'openapi-types';
import { request } from 'undici';
import { hashOpenApiDocument } from '~/server/headscale/api/hasher';
import type { OpenAPIV2 } from "openapi-types";
const HASH_FILE_LOCATION = 'app/openapi-operation-hashes.json';
const CANONICAL_LOCATION = 'app/openapi-canonical-families.json';
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { cwd } from "node:process";
import { request } from "undici";
import { hashOpenApiDocument } from "~/server/headscale/api/hasher";
const HASH_FILE_LOCATION = "app/openapi-operation-hashes.json";
const CANONICAL_LOCATION = "app/openapi-canonical-families.json";
const SPEC_MAP = {
// '0.25.0': '/v0.25.0/gen/openapiv2/headscale/v1/headscale.swagger.json',
// '0.25.1': '/v0.25.1/gen/openapiv2/headscale/v1/headscale.swagger.json',
'0.26.0': '/v0.26.0/gen/openapiv2/headscale/v1/headscale.swagger.json',
'0.26.1': '/v0.26.1/gen/openapiv2/headscale/v1/headscale.swagger.json',
'0.27.0': '/v0.27.0/gen/openapiv2/headscale/v1/headscale.swagger.json',
'0.27.1': '/v0.27.1/gen/openapiv2/headscale/v1/headscale.swagger.json',
// '0.25.0': '/v0.25.0/gen/openapiv2/headscale/v1/headscale.swagger.json',
// '0.25.1': '/v0.25.1/gen/openapiv2/headscale/v1/headscale.swagger.json',
"0.26.0": "/v0.26.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
"0.26.1": "/v0.26.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
"0.27.0": "/v0.27.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
"0.27.1": "/v0.27.1/gen/openapiv2/headscale/v1/headscale.swagger.json",
"0.28.0": "/v0.28.0/gen/openapiv2/headscale/v1/headscale.swagger.json",
} as const;
async function hashOpenApiOperations(specUrl: string) {
const url = `https://raw.githubusercontent.com/juanfont/headscale${specUrl}`;
const res = await request(url);
if (res.statusCode !== 200) {
console.error('Failed to fetch OpenAPI spec:', res.statusCode);
process.exit(1);
}
const url = `https://raw.githubusercontent.com/juanfont/headscale${specUrl}`;
const res = await request(url);
if (res.statusCode !== 200) {
console.error("Failed to fetch OpenAPI spec:", res.statusCode);
process.exit(1);
}
const body = (await res.body.json()) as OpenAPIV2.Document;
return hashOpenApiDocument(body);
const body = (await res.body.json()) as OpenAPIV2.Document;
return hashOpenApiDocument(body);
}
async function collectCanonicalizedFamilies(
newHashes: readonly (readonly [string, Record<string, string>])[],
newHashes: readonly (readonly [string, Record<string, string>])[],
) {
const canonicalizedFamilies: Record<string, string[]> = {};
for (const [version, hashes] of newHashes) {
const signature = JSON.stringify(hashes);
let canonical: string | null = null;
const canonicalizedFamilies: Record<string, string[]> = {};
for (const [version, hashes] of newHashes) {
const signature = JSON.stringify(hashes);
let canonical: string | null = null;
for (const existingCanonical of Object.keys(canonicalizedFamilies)) {
const existingSignature = JSON.stringify(
newHashes.find(([v]) => v === existingCanonical)![1],
);
for (const existingCanonical of Object.keys(canonicalizedFamilies)) {
const existingSignature = JSON.stringify(
newHashes.find(([v]) => v === existingCanonical)![1],
);
if (existingSignature === signature) {
canonical = existingCanonical;
break;
}
}
if (existingSignature === signature) {
canonical = existingCanonical;
break;
}
}
if (!canonical) {
canonicalizedFamilies[version] = [version];
continue;
}
if (!canonical) {
canonicalizedFamilies[version] = [version];
continue;
}
canonicalizedFamilies[canonical].push(version);
if (
version.localeCompare(canonical, undefined, {
numeric: true,
sensitivity: 'base',
}) > 0
) {
canonicalizedFamilies[version] = canonicalizedFamilies[canonical];
delete canonicalizedFamilies[canonical];
}
}
canonicalizedFamilies[canonical].push(version);
if (
version.localeCompare(canonical, undefined, {
numeric: true,
sensitivity: "base",
}) > 0
) {
canonicalizedFamilies[version] = canonicalizedFamilies[canonical];
delete canonicalizedFamilies[canonical];
}
}
for (const [canonical, family] of Object.entries(canonicalizedFamilies)) {
canonicalizedFamilies[canonical] = family.sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' }),
);
}
for (const [canonical, family] of Object.entries(canonicalizedFamilies)) {
canonicalizedFamilies[canonical] = family.sort((a, b) =>
a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }),
);
}
return canonicalizedFamilies;
return canonicalizedFamilies;
}
async function writeHashes(hashes: Record<string, Record<string, string>>) {
const path = resolve(cwd(), HASH_FILE_LOCATION);
await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, 'utf-8');
const path = resolve(cwd(), HASH_FILE_LOCATION);
await writeFile(path, `${JSON.stringify(hashes, null, 2)}\n`, "utf-8");
}
async function writeCanonicalizedFamilies(families: Record<string, string[]>) {
const path = resolve(cwd(), CANONICAL_LOCATION);
await writeFile(path, `${JSON.stringify(families, null, 2)}\n`, 'utf-8');
const path = resolve(cwd(), CANONICAL_LOCATION);
await writeFile(path, `${JSON.stringify(families, null, 2)}\n`, "utf-8");
}
const newHashes = await Promise.all(
Object.entries(SPEC_MAP).map(async ([version, specUrl]) => {
const hashes = await hashOpenApiOperations(specUrl);
return [version, hashes] as const;
}),
Object.entries(SPEC_MAP).map(async ([version, specUrl]) => {
const hashes = await hashOpenApiOperations(specUrl);
return [version, hashes] as const;
}),
);
const canonicalizedFamilies = await collectCanonicalizedFamilies(newHashes);
console.log('Writing new OpenAPI operation hashes to file');
console.log("Writing new OpenAPI operation hashes to file");
await writeHashes(Object.fromEntries(newHashes));
await writeCanonicalizedFamilies(canonicalizedFamilies);
+62 -56
View File
@@ -1,70 +1,76 @@
import { describe, expect, test } from 'vitest';
import { getNode, getRuntimeClient, HS_VERSIONS } from './setup/env';
import { describe, expect, test } from "vitest";
describe.sequential.for(HS_VERSIONS)('Headscale %s: Users', (version) => {
let workingNodeId: string;
import { getBootstrapClient, getNode, getRuntimeClient, HS_VERSIONS } from "./setup/env";
test('nodes can register via their nodekey', async () => {
const client = await getRuntimeClient(version);
const tailnetNode = await getNode(version);
describe.sequential.for(HS_VERSIONS)("Headscale %s: Users", (version) => {
let workingNodeId: string;
const user = await client.createUser('node-reg@');
const node = await client.registerNode(user.name, tailnetNode.authCode);
expect(node).toBeDefined();
expect(node.registerMethod).toBe('REGISTER_METHOD_CLI');
expect(node.name).toBe(tailnetNode.nodeName);
});
test("nodes can register via their nodekey", async () => {
const client = await getRuntimeClient(version);
const tailnetNode = await getNode(version);
test('nodes can be retrieved', async () => {
const client = await getRuntimeClient(version);
const { nodeName } = await getNode(version);
const nodes = await client.getNodes();
const node = nodes.find((n) => n.name === nodeName);
expect(node).toBeDefined();
expect(node?.name).toBe(nodeName);
const user = await client.createUser("node-reg@");
const node = await client.registerNode(user.name, tailnetNode.authCode);
expect(node).toBeDefined();
expect(node.registerMethod).toBe("REGISTER_METHOD_CLI");
expect(node.name).toBe(tailnetNode.nodeName);
});
const fetchedNode = await client.getNode(node!.id);
expect(fetchedNode).toBeDefined();
expect(fetchedNode.id).toBe(node!.id);
workingNodeId = node!.id;
});
test("nodes can be retrieved", async () => {
const client = await getRuntimeClient(version);
const { nodeName } = await getNode(version);
const nodes = await client.getNodes();
const node = nodes.find((n) => n.name === nodeName);
expect(node).toBeDefined();
expect(node?.name).toBe(nodeName);
test('nodes can be renamed', async () => {
const client = await getRuntimeClient(version);
const { nodeName } = await getNode(version);
const newName = `${nodeName}-renamed`;
const fetchedNode = await client.getNode(node!.id);
expect(fetchedNode).toBeDefined();
expect(fetchedNode.id).toBe(node!.id);
workingNodeId = node!.id;
});
await client.renameNode(workingNodeId, newName);
const renamedNode = await client.getNode(workingNodeId);
expect(renamedNode).toBeDefined();
expect(renamedNode.givenName).toBe(newName);
});
test("nodes can be renamed", async () => {
const client = await getRuntimeClient(version);
const { nodeName } = await getNode(version);
const newName = `${nodeName}-renamed`;
test('nodes can be reassigned to another user', async () => {
const client = await getRuntimeClient(version);
const user = await client.createUser('node-reassign@');
await client.renameNode(workingNodeId, newName);
const renamedNode = await client.getNode(workingNodeId);
expect(renamedNode).toBeDefined();
expect(renamedNode.givenName).toBe(newName);
});
await client.setNodeUser(workingNodeId, user.id);
const reassignedNode = await client.getNode(workingNodeId);
expect(reassignedNode).toBeDefined();
expect(reassignedNode.user.name).toBe(user.name);
});
test("nodes can be reassigned to another user", async (context) => {
const bootstrap = await getBootstrapClient(version);
if (bootstrap.clientHelpers.isAtleast("0.28.0")) {
context.skip();
}
test('nodes can be expired', async () => {
const client = await getRuntimeClient(version);
await client.expireNode(workingNodeId);
const client = await getRuntimeClient(version);
const user = await client.createUser("node-reassign@");
const expiredNode = await client.getNode(workingNodeId);
expect(expiredNode).toBeDefined();
expect(expiredNode.expiry).toBeDefined();
});
await client.setNodeUser(workingNodeId, user.id);
const reassignedNode = await client.getNode(workingNodeId);
expect(reassignedNode).toBeDefined();
expect(reassignedNode.user.name).toBe(user.name);
});
test('nodes can be deleted', async () => {
const client = await getRuntimeClient(version);
await client.deleteNode(workingNodeId);
test("nodes can be expired", async () => {
const client = await getRuntimeClient(version);
await client.expireNode(workingNodeId);
const nodes = await client.getNodes();
const node = nodes.find((n) => n.id === workingNodeId);
expect(node).toBeUndefined();
});
const expiredNode = await client.getNode(workingNodeId);
expect(expiredNode).toBeDefined();
expect(expiredNode.expiry).toBeDefined();
});
test("nodes can be deleted", async () => {
const client = await getRuntimeClient(version);
await client.deleteNode(workingNodeId);
const nodes = await client.getNodes();
const node = nodes.find((n) => n.id === workingNodeId);
expect(node).toBeUndefined();
});
});
+54 -87
View File
@@ -1,99 +1,66 @@
import { describe, expect, test } from 'vitest';
import { getRuntimeClient, HS_VERSIONS } from './setup/env';
import { describe, expect, test } from "vitest";
describe.sequential.for(HS_VERSIONS)(
'Headscale %s: Pre-auth Keys',
(version) => {
test('pre-auth keys can be created', async () => {
const client = await getRuntimeClient(version);
const preAuthKeyUser = await client.createUser('preauthkeyuser@');
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe('preauthkeyuser@');
import { getRuntimeClient, HS_VERSIONS } from "./setup/env";
const expiry = new Date(Date.now() + 3600 * 1000);
const preAuthKey = await client.createPreAuthKey(
preAuthKeyUser.id,
false,
false,
expiry,
null,
);
describe.sequential.for(HS_VERSIONS)("Headscale %s: Pre-auth Keys", (version) => {
test("pre-auth keys can be created", async () => {
const client = await getRuntimeClient(version);
const preAuthKeyUser = await client.createUser("preauthkeyuser@");
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.ephemeral).toBe(false);
expect(preAuthKey.reusable).toBe(false);
expect(preAuthKey.aclTags).toEqual([]);
expect(new Date(preAuthKey.expiration).getTime()).toBeCloseTo(
expiry.getTime(),
-2,
);
});
const expiry = new Date(Date.now() + 3600 * 1000);
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, false, false, expiry, null);
test('pre-auth keys can be created with ACL tags', async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(
undefined,
'preauthkeyuser@',
);
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe('preauthkeyuser@');
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.ephemeral).toBe(false);
expect(preAuthKey.reusable).toBe(false);
expect(preAuthKey.aclTags).toEqual([]);
expect(new Date(preAuthKey.expiration).getTime()).toBeCloseTo(expiry.getTime(), -2);
});
const aclTags = ['tag:test1', 'tag:test2'];
const preAuthKey = await client.createPreAuthKey(
preAuthKeyUser.id,
true,
true,
null,
aclTags,
);
test("pre-auth keys can be created with ACL tags", async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.ephemeral).toBe(true);
expect(preAuthKey.reusable).toBe(true);
expect(preAuthKey.aclTags.sort()).toEqual(aclTags.sort());
});
const aclTags = ["tag:test1", "tag:test2"];
const preAuthKey = await client.createPreAuthKey(preAuthKeyUser.id, true, true, null, aclTags);
test('pre-auth keys can be listed', async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(
undefined,
'preauthkeyuser@',
);
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe('preauthkeyuser@');
expect(preAuthKey).toBeDefined();
expect(preAuthKey.user.id).toBe(preAuthKeyUser.id);
expect(preAuthKey.ephemeral).toBe(true);
expect(preAuthKey.reusable).toBe(true);
expect(preAuthKey.aclTags.sort()).toEqual(aclTags.sort());
});
const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
expect(Array.isArray(preAuthKeys)).toBe(true);
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
});
test("pre-auth keys can be listed", async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
test('pre-auth keys can be expired', async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(
undefined,
'preauthkeyuser@',
);
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe('preauthkeyuser@');
const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
expect(Array.isArray(preAuthKeys)).toBe(true);
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
});
const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
const preAuthKeyToExpire = preAuthKeys[0];
test("pre-auth keys can be expired", async () => {
const client = await getRuntimeClient(version);
const [preAuthKeyUser] = await client.getUsers(undefined, "preauthkeyuser@");
expect(preAuthKeyUser).toBeDefined();
expect(preAuthKeyUser.name).toBe("preauthkeyuser@");
await client.expirePreAuthKey(preAuthKeyUser.id, preAuthKeyToExpire.key);
const preAuthKeys = await client.getPreAuthKeys(preAuthKeyUser.id);
expect(preAuthKeys.length).toBeGreaterThanOrEqual(2);
const preAuthKeyToExpire = preAuthKeys[0];
const preAuthKeysAfterExpire = await client.getPreAuthKeys(
preAuthKeyUser.id,
);
const expiredKey = preAuthKeysAfterExpire.find(
(key) => key.key === preAuthKeyToExpire.key,
);
expect(expiredKey).toBeDefined();
expect(new Date(expiredKey!.expiration).getTime()).toBeLessThanOrEqual(
Date.now(),
);
});
},
);
await client.expirePreAuthKey(preAuthKeyUser.id, preAuthKeyToExpire.key);
const preAuthKeysAfterExpire = await client.getPreAuthKeys(preAuthKeyUser.id);
const expiredKey = preAuthKeysAfterExpire.find((key) => key.key === preAuthKeyToExpire.key);
expect(expiredKey).toBeDefined();
});
});
+5
View File
@@ -43,6 +43,11 @@ export async function getRuntimeClient(version: Version) {
return bootstrap.getRuntimeClient(env.apiKey);
}
export async function getIsAtLeast(version: Version) {
const { env, bootstrap } = await ensureVersion(version);
return bootstrap.clientHelpers.isAtleast;
}
export async function getNode(version: Version) {
const { tailscaleNode } = await ensureVersion(version);
return {
+69 -71
View File
@@ -1,83 +1,81 @@
import { describe, expect, test } from 'vitest';
import type { PartialHeadplaneConfigWithPaths } from '~/server/config/config-schema';
import { ConfigError } from '~/server/config/error';
import { loadConfigKeyPaths } from '~/server/config/load';
import { createFakeFile } from '../setup/overlay-fs';
import { describe, expect, test } from "vitest";
describe('Configuration secret path handling', () => {
test('should correctly substitute server.cookie_secret', async () => {
createFakeFile('/secrets/cookie_secret.txt', 'supersecretcookievalue');
const config = {
server: {
cookie_secret_path: '/secrets/cookie_secret.txt',
},
} as PartialHeadplaneConfigWithPaths;
import type { PartialHeadplaneConfigWithPaths } from "~/server/config/config-schema";
await loadConfigKeyPaths(config);
expect(config.server?.cookie_secret).toBe('supersecretcookievalue');
});
import { ConfigError } from "~/server/config/error";
import { loadConfigKeyPaths } from "~/server/config/load";
test('should throw error for missing secret file', async () => {
const config = {
server: {
cookie_secret_path: '/secrets/missing_cookie_secret.txt',
},
} as PartialHeadplaneConfigWithPaths;
import { createFakeFile } from "../setup/overlay-fs";
await expect(loadConfigKeyPaths(config)).rejects.toMatchObject(
ConfigError.from('MISSING_SECRET_FILE', {
pathKey: 'server.cookie_secret_path',
filePath: '/secrets/missing_cookie_secret.txt',
}),
);
});
describe("Configuration secret path handling", () => {
test("should correctly substitute server.cookie_secret", async () => {
createFakeFile("/secrets/cookie_secret.txt", "supersecretcookievalue");
const config = {
server: {
cookie_secret_path: "/secrets/cookie_secret.txt",
},
} as PartialHeadplaneConfigWithPaths;
test('should throw error for conflicting secret path and field', async () => {
const config = {
server: {
cookie_secret: 'explicitsecretvalue',
cookie_secret_path: '/secrets/cookie_secret.txt',
},
} as PartialHeadplaneConfigWithPaths;
await loadConfigKeyPaths(config);
expect(config.server?.cookie_secret).toBe("supersecretcookievalue");
});
await expect(loadConfigKeyPaths(config)).rejects.toMatchObject(
ConfigError.from('CONFLICTING_SECRET_PATH_FIELD', {
fieldName: 'server.cookie_secret',
}),
);
});
test("should throw error for missing secret file", async () => {
const config = {
server: {
cookie_secret_path: "/secrets/missing_cookie_secret.txt",
},
} as PartialHeadplaneConfigWithPaths;
test('should correctly interpolate env vars in secret paths', async () => {
process.env.HP_TEST_COOKIE_SECRET_FILE = 'cookie_secret.txt';
createFakeFile(
`/secrets/${process.env.HP_TEST_COOKIE_SECRET_FILE}`,
'envvarsecretvalue',
);
await expect(loadConfigKeyPaths(config)).rejects.toMatchObject(
ConfigError.from("MISSING_SECRET_FILE", {
pathKey: "server.cookie_secret_path",
filePath: "/secrets/missing_cookie_secret.txt",
}),
);
});
const config = {
server: {
// biome-ignore lint/suspicious/noTemplateCurlyInString: Test supports interpolation
cookie_secret_path: '/secrets/${HP_TEST_COOKIE_SECRET_FILE}',
},
} as PartialHeadplaneConfigWithPaths;
test("should throw error for conflicting secret path and field", async () => {
const config = {
server: {
cookie_secret: "explicitsecretvalue",
cookie_secret_path: "/secrets/cookie_secret.txt",
},
} as PartialHeadplaneConfigWithPaths;
await loadConfigKeyPaths(config);
expect(config.server?.cookie_secret).toBe('envvarsecretvalue');
});
await expect(loadConfigKeyPaths(config)).rejects.toMatchObject(
ConfigError.from("CONFLICTING_SECRET_PATH_FIELD", {
fieldName: "server.cookie_secret",
}),
);
});
test('should throw error for missing interpolated env var in secret path', async () => {
const config = {
server: {
// biome-ignore lint/suspicious/noTemplateCurlyInString: Test supports interpolation
cookie_secret_path: '/secrets/${MISSING_ENV_VAR}',
},
} as PartialHeadplaneConfigWithPaths;
test("should correctly interpolate env vars in secret paths", async () => {
process.env.HP_TEST_COOKIE_SECRET_FILE = "cookie_secret.txt";
createFakeFile(`/secrets/${process.env.HP_TEST_COOKIE_SECRET_FILE}`, "envvarsecretvalue");
await expect(loadConfigKeyPaths(config)).rejects.toMatchObject(
ConfigError.from('MISSING_INTERPOLATION_VARIABLE', {
pathKey: 'server.cookie_secret_path',
variableName: 'MISSING_ENV_VAR',
}),
);
});
const config = {
server: {
cookie_secret_path: "/secrets/${HP_TEST_COOKIE_SECRET_FILE}",
},
} as PartialHeadplaneConfigWithPaths;
await loadConfigKeyPaths(config);
expect(config.server?.cookie_secret).toBe("envvarsecretvalue");
});
test("should throw error for missing interpolated env var in secret path", async () => {
const config = {
server: {
cookie_secret_path: "/secrets/${MISSING_ENV_VAR}",
},
} as PartialHeadplaneConfigWithPaths;
await expect(loadConfigKeyPaths(config)).rejects.toMatchObject(
ConfigError.from("MISSING_INTERPOLATION_VARIABLE", {
pathKey: "server.cookie_secret_path",
variableName: "MISSING_ENV_VAR",
}),
);
});
});