Compare commits

...

375 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
Aarnav Tale e09c5760af chore: random quick fixes 2025-12-04 04:11:52 -05:00
Aarnav Tale a824ea3dcf chore: spammy log cleanup 2025-12-04 03:37:48 -05:00
Aarnav Tale bf3fdd47cd chore: changelog 2025-12-04 00:49:49 -05:00
Aarnav Tale f6cdad535a chore: update to vite 8 beta 2025-12-04 00:48:59 -05:00
Aarnav Tale ba61656fb0 feat: cleanup oidc logic and surface errors better 2025-12-04 00:44:31 -05:00
Aarnav Tale d363ed5486 chore: add tests for nodes 2025-12-03 19:05:54 -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
Aarnav Tale 45de5f6833 fix: define log debug 2025-12-02 20:22:18 -05:00
Aarnav Tale afc5d5451a chore: nix hash omg 2025-12-01 02:57:39 -05:00
Aarnav Tale ef6563742c chore: update api versions 2025-12-01 02:48:40 -05:00
Aarnav Tale 86184e3420 feat: overhaul config loading 2025-12-01 02:46:22 -05: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
Aarnav Tale d3d7c7cc0e feat: better error handling for oidc api key
This also ships with a better error page
2025-11-28 17:54:52 -05:00
Aarnav Tale c0c4ecf631 Merge pull request #370 from llluiix/fix/update-docker-api-version 2025-11-22 09:43:06 -05:00
Luiz Fernando Oliveira dos Santos 5ea96db816 fix(docker): bump docker api version to v1.44 2025-11-21 19:02:16 -03:00
Aarnav Tale cc533f1b72 fix: shut up vitest 2025-11-20 18:16:53 -05:00
Aarnav Tale 3d9b6370bf chore: refactor unit tests 2025-11-18 02:11:53 -05:00
Aarnav Tale afd512138b fix: nix is the death of me 2025-11-16 18:35:17 -05:00
Aarnav Tale 08a251cc8c feat: add e2e integration tests and canonical versioning 2025-11-16 18:31:09 -05:00
Aarnav Tale 63d1e84ebe chore: update deps 2025-11-16 18:28:13 -05:00
Aarnav Tale 20f5342ea3 Merge pull request #367 from tale/update_flake_lock_action 2025-11-16 16:10:13 -05:00
Aarnav Tale db850942ef Merge pull request #362 from itzTheMeow/main 2025-11-16 16:09:29 -05:00
github-actions[bot] c9986ba466 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/e1ebeec86b771e9d387dd02d82ffdc77ac753abc?narHash=sha256-g/da4FzvckvbiZT075Sb1/YDNDr%2BtGQgh4N8i5ceYMg%3D' (2025-11-07)
  → 'github:nixos/nixpkgs/1d4c88323ac36805d09657d13a5273aea1b34f0c?narHash=sha256-esRhOS0APE6k40Hs/jjReXg%2Brx%2BJ5LkWw7cuWFKlwYA%3D' (2025-11-15)
2025-11-16 08:25:27 +00:00
Aarnav Tale d331a22951 Merge pull request #363 from alexfornuto/patch-1 2025-11-11 00:58:37 -05:00
Aarnav Tale 6be801e9bb Merge pull request #364 from tale/update_flake_lock_action 2025-11-11 00:56:09 -05:00
github-actions[bot] 299859e279 flake.lock: Update
Flake lock file updates:

• Updated input 'devshell':
    'github:numtide/devshell/7c9e793ebe66bcba8292989a68c0419b737a22a0?narHash=sha256-kWNaq6wQUbUMlPgw8Y%2B9/9wP0F8SHkjy24/mN3UAppg%3D' (2025-03-08)
  → 'github:numtide/devshell/07bacc9531f5f4df6657c0a02a806443685f384a?narHash=sha256-RXN%2Blcx4DEn3ZS%2BLqEJSUu/HH%2BdwGvy0syN7hTo/Chg%3D' (2025-11-07)
• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/a7fc11be66bdfb5cdde611ee5ce381c183da8386?narHash=sha256-QoJjGd4NstnyOG4mm4KXF%2BweBzA2AH/7gn1Pmpfcb0A%3D' (2025-10-31)
  → 'github:nixos/nixpkgs/e1ebeec86b771e9d387dd02d82ffdc77ac753abc?narHash=sha256-g/da4FzvckvbiZT075Sb1/YDNDr%2BtGQgh4N8i5ceYMg%3D' (2025-11-07)
2025-11-09 08:23:05 +00:00
Meow 5090733013 fix: change nodeList to existingTags 2025-11-09 01:19:59 -05:00
Alex Fornuto 7d77f2cb81 Update default dns_records_path in config.example.yaml
The example path looked for Headscale's `extra_records.json` file in `/var/lib/headplane`, not `/var/lib/headscale`.
2025-11-07 15:03:59 -06:00
Meow c3e07e4e10 feat: change node tag dialog to use Select 2025-11-06 19:49:55 -05:00
Aarnav Tale 1aa921aebe chore: update deps 2025-11-05 00:00:35 -05:00
Aarnav Tale 444b2325fb feat: fix acl api logic to work correctly 2025-11-04 23:51:36 -05:00
Aarnav Tale c84e9ca4a8 feat: update more primitives to use the new api 2025-11-04 23:06:56 -05:00
Aarnav Tale d68737e410 feat: switch machine apis to new runtime api client 2025-11-04 22:44:58 -05:00
Aarnav Tale a68aedc297 feat: create type-safe API object for Headscale 2025-11-04 22:40:57 -05:00
Aarnav Tale 1c0561edb8 Merge pull request #357 from tale/update_flake_lock_action 2025-11-04 13:07:55 -05:00
Aarnav Tale e883a7cd63 Merge pull request #358 from rhoriguchi/main 2025-11-03 09:13:22 -05:00
Ryan Horiguchi cc2112a805 feat: add missing nix options cookie_max_age and cookie_domain 2025-11-03 02:24:41 +01:00
Aarnav Tale afde3399e1 fix: docs build 2025-11-02 16:43:14 -05:00
Aarnav Tale b1c93512df feat: support custom cookie options 2025-11-02 14:04:38 -05:00
Aarnav Tale ce4be73faf feat: add a warning banner on the login page for misconfigured cookies 2025-11-02 13:54:45 -05:00
github-actions[bot] 192cce23b0 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/02f2cb8e0feb4596d20cc52fda73ccee960e3538?narHash=sha256-tH3wHnOJms%2BU4k/rK2Nn1RfBrhffX92jLP/2VndSn0w%3D' (2025-10-24)
  → 'github:nixos/nixpkgs/a7fc11be66bdfb5cdde611ee5ce381c183da8386?narHash=sha256-QoJjGd4NstnyOG4mm4KXF%2BweBzA2AH/7gn1Pmpfcb0A%3D' (2025-10-31)
2025-11-02 08:23:15 +00:00
Aarnav Tale 7d7e08ee54 chore: pin node version to lts in engines.node 2025-10-31 10:23:01 -04:00
Aarnav Tale 06a667b561 chore: simplify ci pipeline 2025-10-31 02:05:58 -04:00
Aarnav Tale 75c688fd00 Merge pull request #335 from tale/next 2025-10-31 00:06:09 -04:00
Aarnav Tale 28ad1d439f Merge branch 'main' into next 2025-10-30 23:52:35 -04:00
Aarnav Tale 135d2940f1 chore: fix hash 2025-10-30 23:38:54 -04:00
Aarnav Tale 497a782f17 docs: fix link typo for configuration 2025-10-30 17:30:13 -04:00
Aarnav Tale 6d5e44f585 fix: pnpm lifecycle scripts broke nix 2025-10-28 09:58:53 -04:00
Aarnav Tale 94e5cb95c6 Merge pull request #342 from tale/update_flake_lock_action 2025-10-27 22:00:56 -04:00
Aarnav Tale 800d73248d Merge pull request #349 from tituspijean/patch-1 2025-10-27 22:00:14 -04:00
tituspijean 66b6dea068 fix: write_ahead_log in sqlite config schema 2025-10-27 21:57:52 +01:00
github-actions[bot] 489b12460f flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/362791944032cb532aabbeed7887a441496d5e6e?narHash=sha256-gKl2Gtro/LNf8P%2B4L3S2RsZ0G390ccd5MyXYrTdMCFE%3D' (2025-10-11)
  → 'github:nixos/nixpkgs/02f2cb8e0feb4596d20cc52fda73ccee960e3538?narHash=sha256-tH3wHnOJms%2BU4k/rK2Nn1RfBrhffX92jLP/2VndSn0w%3D' (2025-10-24)
2025-10-26 08:23:59 +00:00
Aarnav Tale ef8dcec9c8 chore: changelog 2025-10-19 15:35:41 -04:00
Aarnav Tale f66f778398 chore: add sig handlers for the server 2025-10-19 15:29:40 -04:00
Aarnav Tale 6eabfb7610 feat: support headplane compiled without ssh 2025-10-19 15:29:13 -04:00
Aarnav Tale a68df033a2 chore: switch to build script 2025-10-19 14:09:56 -04:00
Aarnav Tale 53b8f76ac6 Merge remote-tracking branch 'origin/main' into next 2025-10-19 13:05:36 -04:00
Aarnav Tale df4d864838 docs: man why is this case sensitive 2025-10-16 22:30:15 -04:00
Aarnav Tale c9bae42622 chore: ok mise be stupid 2025-10-16 22:30:12 -04:00
Aarnav Tale a2a28e747b chore: nix hash 2025-10-16 22:16:26 -04:00
Aarnav Tale d175171efc chore: document agent procedure 2025-10-16 22:16:11 -04:00
Aarnav Tale ac9ce72078 feat: automatically build go modules during pnpm build 2025-10-16 22:01:46 -04:00
Aarnav Tale f44881b399 docs: update images 2025-10-16 21:56:04 -04:00
Aarnav Tale 3b93f2fb61 chore: more docs cleanup/unification 2025-10-16 21:18:27 -04:00
Aarnav Tale 1b2d7e305a docs: new installation docs! 2025-10-16 21:03:40 -04:00
Aarnav Tale 639e7e9c6b fix: reorganize vitepress docs into /docs 2025-10-16 18:29:51 -04:00
Aarnav Tale 5d94667186 fix: update docs build script 2025-10-16 13:30:14 -04:00
Aarnav Tale 225dfceddb chore: changelog 2025-10-15 18:18:26 -04:00
Aarnav Tale cd379cd10d Merge remote-tracking branch 'origin/main' into next 2025-10-15 18:17:09 -04:00
Aarnav Tale 8f260b79f1 feat: bundle all node_modules to reduce size 2025-10-15 18:14:14 -04:00
Aarnav Tale eaffcd7b62 chore: refactor vitepress docs location 2025-10-12 22:03:13 -04:00
Aarnav Tale 4ccc73d7e4 chore: cleanup docs a bit 2025-10-12 15:50:20 -04:00
Aarnav Tale 7a222c47ee chore: remove .tool-versions from dockerfile 2025-10-12 14:47:53 -04:00
Aarnav Tale cb9d0d5317 chore: remove tool-versions to make cloudflare happy 2025-10-12 14:23:58 -04:00
Aarnav Tale bb0d24b708 chore: update changelog date 2025-10-12 14:22:29 -04:00
Aarnav Tale ebe487daf0 Merge pull request #256 from tale/next 2025-10-12 14:19:39 -04:00
Aarnav Tale 4fd8062476 Merge branch 'main' into next 2025-10-12 14:06:28 -04:00
github-actions[bot] 9967ef9e4c chore: update flake.lock (#320)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-10-12 13:54:40 -04:00
Aarnav Tale 43cbc177d6 Merge pull request #325 from igor-ramazanov/next 2025-10-09 14:34:35 -04:00
Igor Ramazanov 0eef9aea9b Bump nixpkgs, remove unnecessary go1251Overlay 2025-10-08 19:59:37 +00:00
Aarnav Tale 9b53711673 feat: redo xterm terminal logic 2025-10-02 13:52:19 -04:00
Aarnav Tale 341a0e8f81 Merge pull request #319 from StealthBadger747/erikp/fix-vite-dev-path 2025-09-27 16:14:43 -04:00
StealthBadger747 cee28fba2f Update nix stuff 2025-09-27 12:01:47 -07:00
Erik Parawell 65f56842de Merge branch 'next' into erikp/fix-vite-dev-path 2025-09-27 11:21:50 -07:00
StealthBadger747 4803b2aa4a Fix routing for vite prefix on dev vs build 2025-09-27 11:14:04 -07:00
Aarnav Tale 1dfb2fb777 fix: upgrade to new tailscale go version to fix ssh 2025-09-27 12:10:15 -04:00
Aarnav Tale 07c5c6d496 docs: mention that oidc clients should be identical 2025-09-27 01:48:58 -04:00
StealthBadger747 4df15fd4b2 Route Vite prefix properly 2025-09-27 01:45:04 -04:00
StealthBadger747 5b138c191a Fix auto populating an empty oidc 2025-09-27 01:44:03 -04:00
github-actions[bot] 957157de5f flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/6d7ec06d6868ac6d94c371458fc2391ded9ff13d?narHash=sha256-fEvTiU4s9lWgW7mYEU/1QUPirgkn%2BodUBTaindgiziY%3D' (2025-09-13)
  → 'github:nixos/nixpkgs/90ade7da38aa49c2e2693a04a44662a0e61530e9?narHash=sha256-H%2BC4c%2BjKCZpSJHODHFaY6b2ciB3Hv00V5QKA7yUVtis%3D' (2025-09-19)
2025-09-27 01:41:40 -04:00
github-actions[bot] 9f0564943b flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/a918bb3594dd243c2f8534b3be01b3cb4ed35fd1?narHash=sha256-ynQxPVN2FIPheUgTFhv01gYLbaiSOS7NgWJPm9LF9D0%3D' (2025-08-30)
  → 'github:nixos/nixpkgs/6d7ec06d6868ac6d94c371458fc2391ded9ff13d?narHash=sha256-fEvTiU4s9lWgW7mYEU/1QUPirgkn%2BodUBTaindgiziY%3D' (2025-09-13)
2025-09-18 23:50:20 -04:00
github-actions[bot] 2fcd19e2b4 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/f937f8ecd1c70efd7e9f90ba13dfb400cf559de4?narHash=sha256-4/Jd%2BLkQ2ssw8luQVkqVs9spDBVE6h/u/hC/tzngsPo%3D' (2025-08-22)
  → 'github:nixos/nixpkgs/a918bb3594dd243c2f8534b3be01b3cb4ed35fd1?narHash=sha256-ynQxPVN2FIPheUgTFhv01gYLbaiSOS7NgWJPm9LF9D0%3D' (2025-08-30)
2025-09-02 09:21:38 -04:00
Aarnav Tale eb4669498a feat: overhaul oidc work 2025-08-28 22:55:26 -04:00
Aarnav Tale bda9dedbfd fix: if there are 0 admins, promote next admin on oidc login 2025-08-28 17:37:12 -04:00
Aarnav Tale a9d5e10be9 fix: redirect admin to admin/ (regression) 2025-08-26 15:18:48 -04:00
github-actions[bot] bd8a7a56d4 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/32f313e49e42f715491e1ea7b306a87c16fe0388?narHash=sha256-nNaeJjo861wFR0tjHDyCnHs1rbRtrMgxAKMoig9Sj/w%3D' (2025-08-15)
  → 'github:nixos/nixpkgs/f937f8ecd1c70efd7e9f90ba13dfb400cf559de4?narHash=sha256-4/Jd%2BLkQ2ssw8luQVkqVs9spDBVE6h/u/hC/tzngsPo%3D' (2025-08-22)
2025-08-24 17:34:54 -04:00
Aarnav Tale d7b1e19985 chore: add deprecation notice for oidc user file 2025-08-21 12:37:25 -04:00
Aarnav Tale 9bc8483a8f chore: shutup the dotenvx shameless promo 2025-08-21 12:16:49 -04:00
Aarnav Tale 4351e1fcb3 feat: support gravatar profile pictures for oidc 2025-08-21 12:16:18 -04:00
Aarnav Tale 9183f805a6 chore: type fixes 2025-08-21 11:50:04 -04:00
Aarnav Tale cd4e8f8399 chore: handle empty (already migrated) user oidc file 2025-08-21 11:49:17 -04:00
Aarnav Tale 6c5656334d chore: update and prune deps 2025-08-21 11:48:59 -04:00
Aarnav Tale ff3bdc1898 feat: remove octicons icon pack 2025-08-21 11:41:16 -04:00
Aarnav Tale 356abab90a feat: do insane type validation for the config 2025-08-21 11:41:14 -04:00
Aarnav Tale 82f6294499 Merge branch 'main' into next 2025-08-20 15:14:17 -04:00
Aarnav Tale 8fc657f86a feat: handle logging from the agent 2025-08-20 14:07:08 -04:00
Aarnav Tale bcd87453bd chore: update go mods 2025-08-20 11:30:10 -04:00
Aarnav Tale 7adacae940 chore: add pnpm.fetcherVersion 2025-08-20 10:11:44 -04:00
Aarnav Tale 797ed56c07 chore: remove github pages workflow 2025-08-19 17:53:34 -04:00
Aarnav Tale ae2bd35cbf chore: update biome to v2 2025-08-19 17:52:26 -04:00
Aarnav Tale d2c4f5eb2b feat: completely overhaul the auth model
* Cookies are now encrypted JWTs (GHSA-wrqq-v7qw-r5w7)
* Authentication is stored in the SQLite database (auto-migrated)
* Session logic is much cleaner
2025-08-19 17:52:16 -04:00
Aarnav Tale 8cb91cd45b feat: overhaul hp_agent lifecycle handling
* Added backoff and liveness probes for better management
* Switched IPC to a simple text based system
* Lookups don't directly touch the agent now
* Use the database as a source of truth
2025-08-19 00:10:06 -04:00
Aarnav Tale a4a037ed68 feat: update to rolldown-vite and typescript-go 2025-08-18 16:42:29 -04:00
Aarnav Tale 17712cbb1c feat: upgrade react-router + hono-server 2025-08-18 16:05:51 -04:00
Aarnav Tale 616b63bb71 chore: add wrangler.toml 2025-08-18 13:37:53 -04:00
Aarnav Tale 361bc493c0 chore: pin vitepress to 2.0.0 and add sponsor link 2025-08-18 13:28:16 -04:00
Aarnav Tale bc3fc5ec5d fix: turns out we need isSsrBuild in vite.config.ts still 2025-08-18 13:11:17 -04:00
Igor Ramazanov c98dc5ed1d feat: nix: docs: improve NixOS docs generations 2025-08-18 13:10:38 -04:00
Igor Ramazanov 9192013bfe feat: documentation website using Vitepress
Adds documentation website using https://vitepress.dev.

Some decisions are questionable, for example creation of a symbolic link
for the README in the `docs/`. I wanted to preserve the ability to read
docs using Github. Maybe not needed.

Another issue, is the page outline for the `NixOS` chapter is too
narrow.

But should serve as a scaffold/basis for doing it properly.
2025-08-18 13:10:38 -04:00
Igor Ramazanov fdbfe584c5 feat: nix: add a new mise task and nix flake output to generate NixOS docs
Adds a new Nix flake output `headplane-nixos-docs` which generates a
Markdown page with all NixOS `services.headplane.settings.*` options.

Replaces existing handwritten options documentation.
2025-08-18 13:10:38 -04:00
github-actions[bot] 97e2557710 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/1ef586712f85b4b004caecd385d6b023e7fd2450?narHash=sha256-aKmIQcvT857VRc3XbLvsuMdLeUuYfz%2B8zQ4eTXe78KE%3D' (2025-08-09)
  → 'github:nixos/nixpkgs/32f313e49e42f715491e1ea7b306a87c16fe0388?narHash=sha256-nNaeJjo861wFR0tjHDyCnHs1rbRtrMgxAKMoig9Sj/w%3D' (2025-08-15)
2025-08-18 13:09:51 -04:00
github-actions[bot] 82a3eb626e flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/7b6929d8b900de3142638310f8bc40cff4f2c507?narHash=sha256-S30TWshtDmNlU30u842RidFUraKj1f2dd4nrKRHm3gE%3D' (2025-08-02)
  → 'github:nixos/nixpkgs/1ef586712f85b4b004caecd385d6b023e7fd2450?narHash=sha256-aKmIQcvT857VRc3XbLvsuMdLeUuYfz%2B8zQ4eTXe78KE%3D' (2025-08-09)
2025-08-15 14:19:46 -07:00
Igor Ramazanov cdcb38f11e fix: nix: hash 2025-08-09 17:09:00 -04:00
github-actions[bot] f20b2c869f flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/6027c30c8e9810896b92429f0092f624f7b1aace?narHash=sha256-cnL5WWn/xkZoyH/03NNUS7QgW5vI7D1i74g48qplCvg%3D' (2025-07-25)
  → 'github:nixos/nixpkgs/7b6929d8b900de3142638310f8bc40cff4f2c507?narHash=sha256-S30TWshtDmNlU30u842RidFUraKj1f2dd4nrKRHm3gE%3D' (2025-08-02)
2025-08-05 16:40:36 -04:00
Aarnav Tale 1355e1535d chore: update changelog 2025-08-05 14:33:29 -04:00
Aarnav Tale ebd219b622 chore: update pnpm nix deps hash 2025-08-05 13:40:43 -04:00
Igor Ramazanov 1ce0dc375e fix: remove direnv and envrc from the repo
I've found it being clumsy and getting in the way.

For example, in my `.envrc` I do `use flake . --override-input nixpkgs
"$NIXPKGS"` to reuse an already existing instance of the `nixpkgs` on my
machine.

Feels like it should be up to a developer on how to use `nix
develop`/`direnv`
2025-08-05 13:16:02 -04:00
Igor Ramazanov 1eaf3abe21 feat: nix: use updated pnpm fetcher version
see:
https://nixos.org/manual/nixpkgs/stable/#javascript-pnpm-fetcherVersion
2025-08-05 13:16:02 -04:00
Igor Ramazanov 8a9235836a fix: agent inherits UID/GID from the parent process 2025-08-05 13:16:02 -04:00
Igor Ramazanov 344901d38c fix: debug log level when searching for a Headscale process 2025-08-05 13:16:02 -04:00
Igor Ramazanov ea004df2bb feat: nix: add debug logging flag 2025-08-05 13:16:02 -04:00
Igor Ramazanov c59632a0b7 feat: nix: match NixOS module to the config schema 2025-08-05 13:16:02 -04:00
Igor Ramazanov 89e38e1356 feat: nix: rename hp_ssh_wasm to headplane-ssh-wasm for consistency 2025-08-05 13:16:02 -04:00
Igor Ramazanov d257b22f91 chore: nix: nix fmt 2025-08-05 13:16:02 -04:00
StealthBadger747 441e31690b Cleanup doc and set sane defaults 2025-08-05 13:16:02 -04:00
Igor Ramazanov e0fde7d0f4 Turn integration.proc.enabled by default 2025-08-05 13:16:02 -04:00
Igor Ramazanov 943e5d2a15 Remove inlined secrets options
Leaving only `_path` variants.
2025-08-05 13:16:02 -04:00
Erik Parawell 7f952bcb3b Nix changes 2025-08-05 13:16:02 -04:00
StealthBadger747 c3112507b5 Copy drizzle as well 2025-08-05 13:16:02 -04:00
StealthBadger747 8b3d0e9bb6 Maybe actually fix builds 2025-08-05 13:16:02 -04:00
StealthBadger747 7a34a01dd0 Got the build working 2025-08-05 13:16:02 -04:00
Igor Ramazanov 51a85e1bfe fix: nix update hash 2025-08-05 13:16:02 -04:00
StealthBadger747 5ab9686460 Update the configuration README
Revert flake.nix and package.nix to main; drop wasm.nix
2025-08-05 13:15:03 -04:00
Erik Parawell 5cfd9e411b Implement path loading
Got the build working

Maybe actually fix builds

Copy drizzle as well
2025-08-05 13:15:03 -04:00
github-actions[bot] 65a14aefcf flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/6b4955211758ba47fac850c040a27f23b9b4008f?narHash=sha256-dPALCtmik9Wr14MGqVXm%2BOQcv7vhPBXcWNIOThGnB/Q%3D' (2025-07-19)
  → 'github:nixos/nixpkgs/6027c30c8e9810896b92429f0092f624f7b1aace?narHash=sha256-cnL5WWn/xkZoyH/03NNUS7QgW5vI7D1i74g48qplCvg%3D' (2025-07-25)
2025-07-29 11:39:21 -04:00
github-actions[bot] f0c1f617c7 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/2a2130494ad647f953593c4e84ea4df839fbd68c?narHash=sha256-Q82Ms%2BFQmgOBkdoSVm%2BFBpuFoeUAffNerR5yVV7SgT8%3D' (2025-07-08)
  → 'github:nixos/nixpkgs/6b4955211758ba47fac850c040a27f23b9b4008f?narHash=sha256-dPALCtmik9Wr14MGqVXm%2BOQcv7vhPBXcWNIOThGnB/Q%3D' (2025-07-19)
2025-07-24 15:52:02 -04:00
github-actions[bot] 44577aff81 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/80d50fc87924c2a0d346372d242c27973cf8cdbf?narHash=sha256-3u6rEbIX9CN/5A5/mc3u0wIO1geZ0EhjvPBXmRDHqWM%3D' (2025-06-27)
  → 'github:nixos/nixpkgs/2a2130494ad647f953593c4e84ea4df839fbd68c?narHash=sha256-Q82Ms%2BFQmgOBkdoSVm%2BFBpuFoeUAffNerR5yVV7SgT8%3D' (2025-07-08)
2025-07-19 12:38:10 -04:00
Domingo Dirutigliano 27b2831f00 displayName correctly integrated 2025-07-14 07:54:18 -04:00
Domingo Dirutigliano ad469129bc fix display name 2025-07-14 07:54:18 -04:00
Aarnav Tale 7691f74d43 fix: basename should always be prefix??? 2025-07-10 11:30:23 -04:00
github-actions[bot] f7547a4c62 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/076e8c6678d8c54204abcb4b1b14c366835a58bb?narHash=sha256-1ovgdmuDYVo5OUC5NzdF%2BV4zx2uT8RtsgZahxidBTyw%3D' (2025-06-20)
  → 'github:nixos/nixpkgs/80d50fc87924c2a0d346372d242c27973cf8cdbf?narHash=sha256-3u6rEbIX9CN/5A5/mc3u0wIO1geZ0EhjvPBXmRDHqWM%3D' (2025-06-27)
2025-06-30 12:17:22 -04:00
Aarnav Tale ce4617d0e1 chore: walk back on nonroot, its too much of a breaking change 2025-06-24 12:23:05 -04:00
Aarnav Tale 6d99aca058 feat: add matrix build for release 2025-06-24 12:06:23 -04:00
Aarnav Tale d809eea564 feat: finalize split debug and distroless container (closes #255) 2025-06-24 11:28:55 -04:00
Aarnav Tale eacde3deb8 chore: attestation is sha256 based, we dont need tags 2025-06-24 11:15:28 -04:00
Aarnav Tale fc7bedc8b1 feat: append -next to pre-release versions in pnpm build 2025-06-24 11:04:24 -04:00
Aarnav Tale ecc547601f fix: pass in github token for mise ratelimits 2025-06-24 11:01:16 -04:00
Aarnav Tale eed12d348e feat: build a debug-shell and distroless container 2025-06-24 10:56:43 -04:00
Aarnav Tale 5a254b746b docs: enforce node lts 22.16 which has sqlite support unflagged 2025-06-24 10:39:51 -04:00
Aarnav Tale 2cc4339499 chore: update changelog 2025-06-23 00:35:50 -04:00
Aarnav Tale 84c820efb1 chore: merge remote-tracking branch 'origin' into next 2025-06-23 00:35:04 -04:00
Gabe Cook 779af42601 fix: username never falling back to email 2025-06-23 00:33:29 -04:00
Aarnav Tale f990670e86 Merge remote-tracking branch 'origin/main' into next 2025-06-22 17:59:03 -04:00
Peder 'hayer' Husom 0344f17887 Proc mode: try to guess PID based on PPID when multiple found 2025-06-22 17:57:52 -04:00
Aarnav Tale f28bfd5f86 fix: add attestation perms to gha 2025-06-22 17:36:22 -04:00
Aarnav Tale 8f1b577b5e fix: allow id-token write in gh actions 2025-06-22 17:32:39 -04:00
github-actions[bot] 6ea9fe169d chore: update flake.lock (#260)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-22 17:28:45 -04:00
Aarnav Tale 9a81b76398 feat: add provenance attestations 2025-06-22 17:28:19 -04:00
Aarnav Tale 144a8b87cd feat: cache docker build cache using gha 2025-06-22 17:17:56 -04:00
Aarnav Tale 2bf088b4a9 feat: speed up docker build 2025-06-22 16:59:21 -04:00
Aarnav Tale 87b8d64bcc feat: reintroduce missing local dns override (fixes #236) 2025-06-21 13:54:33 -04:00
Aarnav Tale 8819af270d fix: properly handle removing the last split dns record
Previously it would set the value to an empty array, breaking DNS resolution for that split completely.
The correct behavior was to remove the key altogether! This fixes #231
2025-06-21 13:08:31 -04:00
Aarnav Tale 1150d1616e fix: support user input when not specified for ssh 2025-06-20 20:09:28 -04:00
Aarnav Tale dd287c0444 fix: menu should work even with ssh target 2025-06-20 19:55:02 -04:00
Aarnav Tale cf55621b57 docs: warn about permission change 2025-06-20 19:46:20 -04:00
Aarnav Tale 73f0a0d273 fix: use distroless and fix build 2025-06-20 11:32:27 -04:00
Aarnav Tale 71f130ede5 feat: switch to libsql since its esm friendly 2025-06-20 10:36:29 -04:00
Aarnav Tale c0af2aa9c8 chore: update changelog 2025-06-20 00:20:47 -04:00
Aarnav Tale b18147fa82 feat: cleanup removal of old ssh plexer and logic
we also have added the necessary logic to auto prune ephemeral nodes because
headscale doesn't seem to automatically remove them. this change made use of a database
which is now stored in the persistent headplane directory.
2025-06-20 00:14:00 -04:00
Aarnav Tale bf1d75a27a fix: resize cols and rows, not the other way around *sigh* 2025-06-18 13:37:47 -04:00
Aarnav Tale 1e86b0e95b feat: add ssh buttons in the ui using hostinfo checks 2025-06-18 11:56:27 -04:00
Aarnav Tale b34a3f0ca1 chore: sigh update pnpm deps hash 2025-06-16 11:45:45 -04:00
Aarnav Tale 799d7b5728 chore: update go deps nix hash 2025-06-16 11:45:26 -04:00
Aarnav Tale 4961e83a9c fix: cleanup agent and add to ci 2025-06-16 11:45:25 -04:00
Aarnav Tale ab7587e7a9 chore: cache go build deps 2025-06-16 11:45:25 -04:00
Aarnav Tale 6d15c411f1 chore: use mise for ci 2025-06-16 11:45:25 -04:00
Aarnav Tale 7f376c3f70 fix: better ssh resilience and customizable timeout for future backoff 2025-06-16 11:45:24 -04:00
Aarnav Tale 6a0e097412 chore: fix tiny nits 2025-06-16 11:45:24 -04:00
Aarnav Tale 721439868f style: format go code on precommit 2025-06-16 11:45:24 -04:00
Aarnav Tale f4af5b920d feat: cleanup and streamline webssh capability 2025-06-16 11:45:23 -04:00
Aarnav Tale 0f9bf73b82 chore: reorganize go code 2025-06-16 11:45:23 -04:00
Aarnav Tale 7a6ad8d2d5 feat: add horrible barely-working go ipn wasm code 2025-06-16 11:45:23 -04:00
Aarnav Tale f9751f5ab2 chore: add tooling for go wasm 2025-06-16 11:45:23 -04:00
Aarnav Tale 46f1576b8f chore: update nix go hash 2025-06-16 11:45:21 -04:00
Aarnav Tale ccde3513dc feat: support resizing and other xterm.js addons 2025-06-16 11:45:00 -04:00
Aarnav Tale a0a80852eb fix: connect to stdout fd after starting an ssh shell
this avoids a race condition that would happen if the goroutine tried to read a
file descriptor that wasn't open properly and would lose frame delivery
2025-06-16 11:45:00 -04:00
Aarnav Tale cb32637938 feat: add xterm frontend ui 2025-06-16 11:44:59 -04:00
Aarnav Tale 55eacb59e9 feat: expand frame type to support stdout/stderr chan 2025-06-16 11:44:59 -04:00
Aarnav Tale 7dfcbef774 feat: initial webssh tooling 2025-06-16 11:44:59 -04:00
github-actions[bot] f6d8ad25e1 chore: update flake.lock (#247)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-16 11:41:50 -04:00
Guillaume e36193a8c0 feat: add logo and favicon (#238) 2025-06-09 10:30:03 -04:00
Antônio Lago 0c87f3e6ec docs: fix acl mode key in docs (#241) 2025-06-09 10:29:51 -04:00
github-actions[bot] 090f5c1ba9 chore: update flake.lock (#243)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-09 10:27:37 -04:00
Sizhe Zhao 9b9d5432c6 fix: update nix deps hash (#244) 2025-06-09 10:26:29 -04:00
github-actions[bot] 2a26a74b65 chore: update flake.lock (#235)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-05 23:07:57 -04:00
Gabe Cook a298686d6b fix: fallback to user email when username is unset (#237) 2025-06-05 23:06:38 -04:00
Aarnav Tale 2f316176c8 fix: use /proc/pid/comm to check commands 2025-05-29 12:05:30 -04:00
Aarnav Tale 59525b7b63 feat: upgrade to kubernetes client 1.x 2025-05-29 11:55:30 -04:00
Aarnav Tale b7009e387b feat: upgrade to tailwindcss v4 2025-05-29 11:16:47 -04:00
Aarnav Tale e173376319 chore: update deps 2025-05-29 11:16:47 -04:00
github-actions[bot] 59d7f15694 chore: update flake.lock (#216)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-25 11:05:24 -04:00
Aarnav Tale 0866ced767 chore: update changelog version 2025-05-25 11:02:19 -04:00
Aarnav Tale 40a2eb4186 fix: show tags on machine page 2025-05-25 11:02:19 -04:00
Aarnav Tale cc0c7fa61d fix: i am stupid 2025-05-25 11:02:19 -04:00
Aarnav Tale d25e5fd883 fix: properly support default/fallback in arktype 2025-05-25 11:02:19 -04:00
Aarnav Tale 769674ab07 fix: check for invalid go timestamp on expiry again 2025-05-25 11:02:19 -04:00
Aarnav Tale 0276a500c7 fix: attempt to auto create agent work_dir if missing 2025-05-25 11:02:19 -04:00
Aarnav Tale d6fbc3b5ad fix: use corrected user ids for submission fields on the api 2025-05-25 11:02:19 -04:00
Aarnav Tale 662c38ea44 fix: handle newlines in provided secret paths 2025-05-25 11:02:19 -04:00
Aarnav Tale c827f56b6b chore: update changelog to reflect 0.26+ 2025-05-25 11:02:19 -04:00
Aarnav Tale 753a403aed fix: use full users for preauthkey lookup 2025-05-25 11:02:19 -04:00
Aarnav Tale ffce20e88a chore: update docs to match 0.26+ criteria 2025-05-25 11:02:19 -04:00
Aarnav Tale eda8ed0b3e feat: support 0.26+ routes only 2025-05-25 11:02:19 -04:00
Aarnav Tale 59b32c0842 fix: resolve kubernetes import issue 2025-05-25 11:02:19 -04:00
Aarnav Tale 68bd6200fc chore: update changelog 2025-05-25 11:02:19 -04:00
Aarnav Tale f56d4e9d9f chore: tighten pid logic 2025-05-25 11:02:19 -04:00
Giorgi Lekveishvili 602e55a94d fix: indentation 2025-05-25 11:02:19 -04:00
Giorgi Lekveishvili 5e60ac1208 fix: improve headscale proc detection
Check if command line also includes 'serve'.
Move command line processing logic into a helper function.
2025-05-25 11:02:19 -04:00
Aarnav Tale b1da29b2fb chore: use single string docker labels 2025-05-25 11:02:19 -04:00
Aarnav Tale 0808299369 fix: use AAAA and A info the record dialog 2025-05-25 11:02:19 -04:00
Aarnav Tale 39d6602263 fix: use descriptive error messages 2025-05-25 11:02:19 -04:00
Aarnav Tale df4f250e0f fix: mention AAAA in description and fix records table layout 2025-05-25 11:02:19 -04:00
Aarnav Tale 129e4f648b fix: when not using dns_records_path, use config dns editor 2025-05-25 11:02:19 -04:00
Aarnav Tale 2a4d23d70f fix: extra_records should be optional 2025-05-25 11:02:19 -04:00
Aarnav Tale f4da4c6428 chore: update docs 2025-05-25 11:02:19 -04:00
Aarnav Tale 7f5e0fe42d fix: resolve some type errors 2025-05-25 11:02:19 -04:00
Aarnav Tale 021ecabd3c feat: add support for extra_records_path in hs config 2025-05-25 11:02:19 -04:00
Aarnav Tale 81feb2f19a feat: add support for AAAA records 2025-05-25 11:02:19 -04:00
Aarnav Tale abedf40690 feat: use host info on the machines page 2025-05-25 11:02:19 -04:00
Aarnav Tale c9874966d2 fix: make docker container name optional 2025-05-25 11:02:19 -04:00
Aarnav Tale 887834efd9 chore: update changelog 2025-05-25 11:02:19 -04:00
Aarnav Tale 6f2a0773ba feat: disable role changing on unmanaged users 2025-05-25 11:02:19 -04:00
Aarnav Tale 8c01fcfe89 fix: only return queried machines from an agent 2025-05-25 11:02:19 -04:00
Aarnav Tale f93acd8ef1 feat: redesign chip attribute 2025-05-25 11:02:19 -04:00
Aarnav Tale a510796e38 fix: validate machine rename input 2025-05-25 11:02:19 -04:00
Aarnav Tale 69d97ea585 fix: allow tags to wrap 2025-05-25 11:02:19 -04:00
Aarnav Tale 000e9326fb fix: hide version column if no agents are connected 2025-05-25 11:02:19 -04:00
Aarnav Tale 8299aac94f fix: show user under machine name 2025-05-25 11:02:19 -04:00
Aarnav Tale 08ee0eff42 feat: redo machine tagging system 2025-05-25 11:02:19 -04:00
Aarnav Tale d1f395acfd feat: rework the machine actions
this also fixes the registration regression introduced in 0.5.8
2025-05-25 11:02:19 -04:00
Aarnav Tale 6bc20bd0e6 docs: update changelog 2025-05-25 11:02:19 -04:00
Aarnav Tale 130b603d58 fix: update pnpm nix hash 2025-05-25 11:02:19 -04:00
Aarnav Tale ae0aadd83f chore: fix nix hash and docs version 2025-05-25 11:02:19 -04:00
Aarnav Tale 6be55ec686 fix: redirect /admin to /admin/ automatically 2025-05-25 11:02:19 -04:00
Aarnav Tale fb3bc35b40 fix: handle the login form state and report errors correctly on api key login 2025-05-25 11:02:19 -04:00
Aarnav Tale dedec8f317 docs: agent config was not under its correct section 2025-05-25 11:02:19 -04:00
Aarnav Tale 7eda09bae8 chore: go mod tidy 2025-05-25 11:02:19 -04:00
Aarnav Tale ed4041073c chore: use config settings in vite dev server 2025-05-25 11:02:19 -04:00
Aarnav Tale c5e6a6c407 feat: switch away from websocket to stdout messaging for agent 2025-05-25 11:02:19 -04:00
Aarnav Tale 608fcacdb1 feat: use a debug logger for agent 2025-05-25 11:02:19 -04:00
Aarnav Tale 3dca401837 feat: rework the entire pre-auth keys page 2025-05-25 11:02:19 -04:00
Aarnav Tale 785d5a663f chore: reorganize settings code layout 2025-05-25 11:02:19 -04:00
Aarnav Tale ece304638f chore: update notice instead of using a custom one on acls 2025-05-25 11:02:19 -04:00
Aarnav Tale 1a2b1ad3f2 fix: route errors should no longer use json 2025-05-25 11:02:19 -04:00
Aarnav Tale 169a7b14d0 feat: support oidc restriction management in the settings 2025-05-25 11:02:19 -04:00
Aarnav Tale 3a5ab68432 chore: defer the path based config loading till 0.6.1. (#223) 2025-05-23 10:46:14 -04:00
Erik Parawell 62863ad649 Cleanup 2025-05-19 16:38:35 -04:00
Erik Parawell 91fcf5233e Rework the documentation to reflect the whitelist approach 2025-05-19 16:38:35 -04:00
Erik Parawell 8ea8c7195f Implement path-based secret loading 2025-05-19 16:38:35 -04:00
github-actions[bot] c3a50ea4f4 chore: update flake.lock (#206)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-14 00:10:00 -04:00
github-actions[bot] 6716e5f0b0 chore: update flake.lock (#202)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-04 15:25:02 -04:00
github-actions[bot] 346b44ec69 chore: update flake.lock (#196)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-03 23:46:31 -04:00
George Ntoutsos faa61b0f1d feat: add filtering by container label for Docker integration (#194) 2025-04-24 19:03:33 -04:00
github-actions[bot] 6b63fe209f chore: update flake.lock (#190)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-20 15:38:03 -04:00
github-actions[bot] c8507cff7c chore: update flake.lock (#180)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-04-15 11:00:11 -04:00
Federico Cerutti b86f4461c0 feat: restart with headscale, update server.js path in systemd example (#184) 2025-04-10 09:08:43 -04:00
Aarnav Tale 77b510c927 docs: oops 2025-04-05 13:14:00 -04:00
Aarnav Tale 5adcb8c582 ci: skip ci build on certain paths 2025-04-05 12:07:31 -04:00
Aarnav Tale 524c5eb639 docs: docker version tags had an extra char 2025-04-05 11:58:32 -04:00
Aarnav Tale 2894c664d3 docs: fix tag typo 2025-04-05 11:56:59 -04:00
Aarnav Tale 5c2d08decd chore: update dep tooling 2025-04-04 16:09:10 -04:00
Aarnav Tale f2e8c6ae4c chore: update dep tooling 2025-04-04 16:06:22 -04:00
Aarnav Tale c3e727842a docs: mention versioning policy 2025-04-04 15:44:16 -04:00
Aarnav Tale 96345ab0a6 chore: switch from nightly to pr based incubration 2025-04-04 10:19:37 -04:00
Aarnav Tale fe2d7cb57a chore: v0.5.10 2025-04-03 23:44:19 -04:00
Aarnav Tale 66c7d9a327 chore: v0.5.9 2025-04-03 23:24:13 -04:00
Aarnav Tale f2747ada94 fix: suppress date and button hydration warnings 2025-04-03 16:54:12 -04:00
Aarnav Tale 0ad578e651 fix: prevent user rename if they are an oidc user 2025-04-03 16:25:30 -04:00
Aarnav Tale 8d1132606a fix: add aria-label to radio groups 2025-04-03 16:22:52 -04:00
Aarnav Tale 5e332c4a5c fix: filter out empty users in auth-keys, potential headscale bug 2025-04-03 16:10:39 -04:00
Aarnav Tale 69c6fc4847 chore: v0.5.8 2025-04-03 13:11:33 -04:00
Aarnav Tale 9b09b13b5f feat: do not login loop if disable_api_key_login is true 2025-04-03 12:57:55 -04:00
Aarnav Tale cf90f3dd32 chore: auto-create /var/lib/headscale in docker 2025-04-03 12:57:55 -04:00
Aarnav Tale 72c1174cb3 fix: make the hidden URL link aria compatible 2025-04-03 12:57:06 -04:00
Aarnav Tale 63bfad77ce fix: add api-error file 2025-04-03 12:57:06 -04:00
Aarnav Tale 41223b89b3 chore: update to headscale 0.25.1 2025-04-03 12:57:06 -04:00
Aarnav Tale 93d5c2ed29 chore: update actions to include next branch 2025-04-03 12:57:06 -04:00
Aarnav Tale 6a94e815f2 feat: improve error returning and parsing logic 2025-04-03 12:57:06 -04:00
Aarnav Tale 234020eec5 feat: support acl capabilities check 2025-04-03 12:57:06 -04:00
Aarnav Tale 58cc7b742c feat: make machine actions permission locked 2025-04-03 12:57:06 -04:00
Aarnav Tale 259d150fc4 feat: add capabilities enforcement on users 2025-04-03 12:57:06 -04:00
Aarnav Tale 5d3fada266 feat: add permissions check on dns page 2025-04-03 12:57:06 -04:00
Aarnav Tale 9d046a0cf6 fix: use new logger on oidc utils 2025-04-03 12:57:06 -04:00
Aarnav Tale 1fb084451d fix: fix integrations not loading 2025-04-03 12:57:06 -04:00
Aarnav Tale d5fb8a2966 feat: support skipping onboarding 2025-04-03 12:57:06 -04:00
Aarnav Tale 7b1340c93e fix: make reassign dialog unactionable if editing owner 2025-04-03 12:57:06 -04:00
Aarnav Tale 16a8122f85 fix: only use basename in ssr build 2025-04-03 12:57:06 -04:00
Aarnav Tale 7d61ad50c4 feat: oops commit the user role change page 2025-04-03 12:57:06 -04:00
Aarnav Tale 103a826178 chore: remove unused code in the user overview 2025-04-03 12:57:06 -04:00
Aarnav Tale 090dec1ca6 chore: update docs 2025-04-02 17:03:58 -04:00
309 changed files with 23474 additions and 11528 deletions
+8 -4
View File
@@ -1,7 +1,11 @@
node_modules
.dockerignore
Dockerfile
/.cache
/.github
/.react-router
/node_modules
/build
/docs
/nix
.dockerignore
.env
Dockerfile
-7
View File
@@ -1,7 +0,0 @@
ROOT_API_KEY=abcdefghijklmnopqrstuvwxyz
COOKIE_SECRET=abcdefghijklmnopqrstuvwxyz
DISABLE_API_KEY_LOGIN=true
HEADSCALE_CONTAINER=headscale
HOST=0.0.0.0
PORT=3000
CONFIG_FILE=/etc/headscale/config.yaml
-1
View File
@@ -1 +0,0 @@
use_flake
+3
View File
@@ -0,0 +1,3 @@
* @tale
/nix @tale @StealthBadger747
+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
+1 -1
View File
@@ -10,7 +10,7 @@ concurrency:
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
contents: write # Read/write access to the repository
pull-requests: write # Allow creating pull requests
jobs:
+25 -25
View File
@@ -1,6 +1,11 @@
name: Build
on:
push:
paths-ignore:
- "assets/**"
- "docs/**"
- "CHANGELOG.md"
- "README.md"
branches:
- "main"
pull_request:
@@ -14,42 +19,37 @@ permissions:
contents: read # Read access to the repository
jobs:
native:
name: native
ci:
name: Build and Test
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install node.js
uses: actions/setup-node@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
node-version: 22
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
- name: Setup Node.js
uses: actions/setup-node@v6
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
node-version-file: package.json
cache: pnpm
- name: Install dependencies
run: pnpm install
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: Build
run: pnpm build
- name: CI pipeline
run: ./build.sh --skip-pnpm-prune
- name: Run unit tests
run: pnpm run test:unit
- name: Run integration tests
run: pnpm run test:integration
nix:
name: nix
+81
View File
@@ -0,0 +1,81 @@
name: Pre-release (next)
on:
workflow_dispatch:
pull_request:
types: [opened, synchronize, reopened]
concurrency:
group: pre-release-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
id-token: write # For the attest action to push
attestations: write # For the attest action to push
jobs:
publish:
# Ensure the action only runs if manually dispatched or a PR on the `next` branch in the *main* repository is opened or synchronized.
if: ${{ github.event_name == 'workflow_dispatch' || (github.event.pull_request && github.event.pull_request.head.repo.full_name == github.repository && github.event.pull_request.head.ref == 'next') }}
name: Docker Pre-release
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: final
tag: 'next'
- target: debug-shell
tag: 'next-shell'
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=raw,value=${{ matrix.tag }}
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and publish ghcr.io/${{ github.repository }}:${{ matrix.tag }}
uses: docker/build-push-action@v6
id: push
with:
context: .
file: ./Dockerfile
target: ${{ matrix.target }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ matrix.tag }}
secrets: |
gh_token=${{ secrets.GITHUB_TOKEN }}
- name: Attestation Provenance for ghcr.io/${{ github.repository }}:${{ matrix.tag }}
uses: actions/attest-build-provenance@v2
id: attest
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
-53
View File
@@ -1,53 +0,0 @@
name: Nightly
on:
schedule:
- cron: "0 8 * * *"
workflow_dispatch:
concurrency:
group: publish-nightly-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
jobs:
publish:
name: docker-publish
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to ghcr.io
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=edge,branch=main
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64
+39 -15
View File
@@ -5,22 +5,43 @@ on:
- "*"
concurrency:
group: publish-${{ github.ref }}
group: release-${{ github.ref }}
cancel-in-progress: true
permissions:
actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository
packages: write # Write access to the container registry
id-token: write # For the attest action to push
attestations: write # For the attest action to push
jobs:
publish:
name: docker-publish
docker:
name: Docker Release
runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: final
tag_suffix: ''
- target: debug-shell
tag_suffix: '-shell'
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
flavor: |
latest=auto
suffix=${{ matrix.tag_suffix }},onlatest=true
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
@@ -34,22 +55,25 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker Metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=false
- name: Build and push Docker image
uses: docker/build-push-action@v5
- name: Build and push ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
uses: docker/build-push-action@v6
id: push
with:
context: .
file: ./Dockerfile
target: ${{ matrix.target }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
IMAGE_TAG=ghcr.io/${{ github.repository }}:${{ fromJSON(steps.meta.outputs.json).labels['org.opencontainers.image.version'] }}
- name: Attestation Provenance for ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
uses: actions/attest-build-provenance@v2
id: attest
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
+10
View File
@@ -2,4 +2,14 @@ node_modules
/.react-router
/.cache
/build
/test
.env
/app/hp_ssh.wasm
/app/wasm_exec.js
/public/hp_ssh.wasm
/public/wasm_exec.js
/docs/.vitepress/dist/
/docs/.vitepress/cache/
/.direnv
-1
View File
@@ -1,2 +1 @@
side-effects-cache = false
public-hoist-pattern[]=*hono*
+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": []
}
-17
View File
@@ -1,17 +0,0 @@
{
"formatter": {
"language_server": {
"name": "biome"
}
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
},
"languages": {
"YAML": {
"tab_size": 2,
"hard_tabs": false
}
}
}
+250 -35
View File
@@ -1,12 +1,136 @@
### 0.5.7 (April 2, 2025)
# 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)).
- Implemented the ability to customize the build with a custom script (see `./build.sh --help` for more information).
- Attempt to warn against misconfigured cookie settings on the login page.
- Made `server.cookie_max_age` and `server.cookie_domain` configurable (closes [#348](https://github.com/tale/headplane/issues/348)).
- Re-worked the configuration loading system with several enhancements:
- It is now possible to skip a configuration file and only use environment variables (closes [#150](https://github.com/tale/headplane/issues/150)).
- Secret path loading has been reworked from the ground up to be more reliable (closes [#334](https://github.com/tale/headplane/issues/334)).
- Added better testing and validation for configuration loading
- Re-worked the OIDC integration to adhere to the correct standards and surface more errors to the user.
- Deprecated `oidc.redirect_uri` and automated callback URL detection in favor of setting `server.base_url` correctly.
- Explicitly added `oidc.use_pkce` to correctly determine PKCE configuration.
- `oidc.token_endpoint_auth_method` is now optional and will attempt to be auto-detected, defaulting to `client_secret_basic` if unavailable (closes [#410](https://github.com/tale/headplane/issues/410)).
- 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.
- 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 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`.
- 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.
- Add a build attestation to validate SLSA provenance for the Docker image.
- 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)
- 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_)
- 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)).
---
# 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.
- 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 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.
- 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)).
- Machine tags now show states when waiting for subnet or exit node approval and when expiry is disabled.
- Expiry status on the UI was incorrectly showing as never due to changes in the Headscale API.
- Added validation for machine renaming to prevent invalid submissions (closes [#192](https://github.com/tale/headplane/issues/192)).
- Unmanaged (non-OIDC) users cannot have a role assigned to them so the menu option was disabled.
- Support Docker container discovery through labels (via [#194](https://github.com/tale/headplane/pull/194)).
- AAAA records are now supported on the DNS page (closes [#189](https://github.com/tale/headplane/issues/189)).
- Add support for `dns.extra_records_path` in the Headscale config (closes [#144](https://github.com/tale/headplane/issues/144)).
- Tighten `proc` integration logic by checking for the `headscale serve` command (via #[195](https://github.com/tale/headplane/pull/195)).
- Strip newlines in the OIDC `client_secret_path` file if provided (closes [#199](https://github.com/tale/headplane/issues/199)).
---
# 0.5.10 (April 4, 2025)
- 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.
---
# 0.5.8 (April 3, 2025)
- You can now skip the onboarding page if desired.
- Added the UI to change user roles in the dashboard.
- Fixed an issue where integrations would throw instead of loading properly.
- Loading the ACL page no longer spams blank updates to the Headscale database (fixes [#151](https://github.com/tale/headplane/issues/151))
- Automatically create `/var/lib/headplane` in the Docker container (fixes [#166](https://github.com/tale/headplane/issues/166))
- OIDC logout with `disable_api_key_login` set to true will not automatically login again (fixes [#149](https://github.com/tale/headplane/issues/149))
---
# 0.5.7 (April 2, 2025)
- Hotfix an issue where assets aren't served under `/admin` or the prefix.
### 0.5.6 (April 2, 2025)
---
# 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.
@@ -14,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.
@@ -21,10 +146,16 @@ If there are no users, the first user will be given an `owner` role which cannot
- Unknown values in the Headplane config no longer cause a crash.
- Fixed an issue that caused copied commands to have a random space (fixes [#161](https://github.com/tale/headplane/issues/161))
### 0.5.5 (March 18, 2025)
---
# 0.5.5 (March 18, 2025)
- Hotfix an issue that caused Headplane to crash if no agents are available
### 0.5.4 (March 18, 2025)
---
# 0.5.4 (March 18, 2025)
- Fixed a typo in the Kubernetes documentation
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
@@ -38,20 +169,32 @@ If there are no users, the first user will be given an `owner` role which cannot
- You can now pass in an OIDC client secret through `oidc.client_secret_path` in the config (fixes [#126](https://github.com/tale/headplane/issues/126))
- Correctly handle differently localized number inputs (fixes [#125](https://github.com/tale/headplane/issues/125))
### 0.5.3 (March 1, 2025)
---
# 0.5.3 (March 1, 2025)
- Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111))
- Added an ARIA indicator for when an input is required and fixed the confirm buttons (fixed [#116](https://github.com/tale/headplane/issues/116))
- Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112))
### 0.5.2 (February 28, 2025)
---
# 0.5.2 (February 28, 2025)
- Hotfixed an issue where the server bundle got reloaded on each request
### 0.5.1 (February 28, 2025)
---
# 0.5.1 (February 28, 2025)
- Fixed an issue that caused the entire server to crash on start
- Fixed the published semver tags from Docker
- Fixed the Kubernetes integration not reading the config
### 0.5 (February 27, 2025)
---
# 0.5 (February 27, 2025)
> This release is a major overhaul and contains a significant breaking change.
> We now use a config file for all settings instead of environment variables.
> Please see [config.example.yaml](/config.example.yaml) for the new format.
@@ -66,10 +209,16 @@ If there are no users, the first user will be given an `owner` role which cannot
- Settings that were previously shared like `public_url` or `oidc` are now separate within Headplane/Headscale. This is a rather large breaking change but fixes cases where a user may choose to utilize Headscale OIDC for Tailscale but not for the Headplane UI.
- Deprecate the `latest` tag in Docker for explicit versioning and `edge` for nightly builds.
### 0.4.1 (January 18, 2025)
---
# 0.4.1 (January 18, 2025)
- Fixed an urgent issue where the OIDC redirect URI would mismatch.
### 0.4.0 (January 18, 2025)
---
# 0.4.0 (January 18, 2025)
- Switched from Remix.run to React-Router
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
@@ -82,29 +231,47 @@ If there are no users, the first user will be given an `owner` role which cannot
- Reusable Pre-Auth Keys no longer show expired when used (PR [#88](https://github.com/tale/headplane/pull/88))
- Tweaked some CSS issues in the UI
### 0.3.9 (December 6, 2024)
---
# 0.3.9 (December 6, 2024)
- Fixed a race condition bug in the OIDC validation code
### 0.3.8 (December 6, 2024)
---
# 0.3.8 (December 6, 2024)
- Added a little HTML footer to show the login page and link to a donation page.
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
### 0.3.7 (November 30, 2024)
---
# 0.3.7 (November 30, 2024)
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
- Fixed an issue where the copy machine registration command had a typo.
### 0.3.6 (November 20, 2024)
---
# 0.3.6 (November 20, 2024)
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
### 0.3.5 (November 8, 2024)
---
# 0.3.5 (November 8, 2024)
- Quickfix a bug where environment variables are ignored on the server.
- Remove a nagging error about missing cookie since that happens when signed out.
### 0.3.4 (November 7, 2024)
---
# 0.3.4 (November 7, 2024)
- Clicking on the machine name in the users page now takes you to the machine overview page.
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
- Fixed a bunch of silly issues introduced by me not typechecking the codebase.
@@ -114,70 +281,106 @@ If there are no users, the first user will be given an `owner` role which cannot
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
- Added a basic check to see if the API keys passed via cookies are invalid.
### 0.3.3 (October 28, 2024)
---
# 0.3.3 (October 28, 2024)
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
- Improved ACL editor performance by switching back to CodeMirror 6
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
### 0.3.2 (October 11, 2024)
---
# 0.3.2 (October 11, 2024)
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
- Handle `null` values on machine expiry when using a database like PostgreSQL.
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
### 0.3.1 (October 3, 2024)
---
# 0.3.1 (October 3, 2024)
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
- Added the option for debug logs with the `DEBUG=true` environment variable.
### 0.3.0 (September 25, 2024)
---
# 0.3.0 (September 25, 2024)
- Bumped the minimum supported version of Headscale to 0.23.
- Updated the UI to respect `dns.use_username_in_magic_dns`.
### 0.2.4 (August 24, 2024)
---
# 0.2.4 (August 24, 2024)
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
- Removed the `ACL_FILE` environment variable since it's no longer needed.
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
- Fixed a bug where removing Split DNS configurations would crash the UI.
### 0.2.3 (August 23, 2024)
---
# 0.2.3 (August 23, 2024)
- Change the minimum required version of Headscale to 0.23-beta2
- Support the new API policy mode for Headscale 0.23-beta1
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
- If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24))
### 0.2.2 (August 2, 2024)
---
# 0.2.2 (August 2, 2024)
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
- Added a new logger utility that shows categories, levels, and timestamps.
- Reimplemented the integration system to be more resilient and log more information.
- Fixed an issue where the /proc integration found `undefined` PIDs.
### 0.2.1 (July 7, 2024)
---
# 0.2.1 (July 7, 2024)
- Added the ability to manage custom DNS records on your Tailnet.
- ACL tags for machines are now able to be changed via the machine menu.
- Fixed a bug where the ACL editor did not show the diffs correctly.
- Fixed an issue that stopped the "Discard changes" button in the ACL editor from working.
### 0.2.0 (June 23, 2024)
---
# 0.2.0 (June 23, 2024)
- Fix the dropdown options for machines not working on the machines page.
- Add an option to change the machine owner in the dropdown (aside from the users page).
### 0.1.9 (June 2, 2024)
---
# 0.1.9 (June 2, 2024)
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
- Utilize magic DNS hostnames for the machine overview page.
- Fixed the expiry issue once and for all.
- Add a nightly build with the `ghcr.io/tale/headplane:edge` tag
### 0.1.8 (June 2, 2024)
---
# 0.1.8 (June 2, 2024)
- Built basic functionality for the machine overview page (by machine ID).
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
- Prevent users from being deleted if they still have ownership of machines.
- Fixed some type issues where `Date` was being used instead of `string` for timestamps.
### 0.1.7 (May 30, 2024)
---
# 0.1.7 (May 30, 2024)
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
- Made the strict configuration loader more lenient to allow for more flexibility.
@@ -185,19 +388,27 @@ If there are no users, the first user will be given an `owner` role which cannot
- Headplane's context now only loads once at start instead of being lazy-loaded.
- Improved logging and error propagation so that it's easier to debug issues.
### 0.1.6 (May 22, 2024)
---
# 0.1.6 (May 22, 2024)
- Added experimental support for advanced integration without Docker.
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
### 0.1.5 (May 20, 2024)
---
# 0.1.5 (May 20, 2024)
- Robust configuration handling with fallbacks based on the headscale source.
- Support for `client_secret_path` on configuration file based OIDC.
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
- `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works).
- Fixed button responders not actually being invoked (should fix the ACL page).
### 0.1.4 (May 15, 2024)
---
# 0.1.4 (May 15, 2024)
- Users can now be created, renamed, and deleted on the users page.
- Machines can be dragged between users to change their ownership.
@@ -206,13 +417,17 @@ If there are no users, the first user will be given an `owner` role which cannot
- Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues).
- Upgrade other dependencies
### 0.1.3 (May 4, 2024)
---
# 0.1.3 (May 4, 2024)
- Switched to a better icon set for the UI.
- Support stable scrollbar gutter if supported by the browser.
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
### 0.1.2 (May 1, 2024)
---
# 0.1.2 (May 1, 2024)
- Added support for renaming, expiring, removing, and managing the routes of a machine.
- Implemented an expiry check for machines which now reflect on the machine table.
+10
View File
@@ -0,0 +1,10 @@
https://localhost {
reverse_proxy headscale:8080
tls /certs/localhost.pem /certs/localhost-key.pem
header {
Access-Control-Allow-Origin *
Access-Control-Allow-Headers *
Access-Control-Allow-Methods GET, POST, OPTIONS
}
}
+70 -10
View File
@@ -1,16 +1,76 @@
FROM node:22-alpine AS build
WORKDIR /app
FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
WORKDIR /run
RUN npm install -g pnpm@10
RUN apk add --no-cache git
COPY package.json pnpm-lock.yaml ./
COPY go.mod go.sum build.sh ./
RUN go mod download
COPY cmd/ ./cmd/
COPY internal/ ./internal/
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 --healthcheck \
--wasm-output /bin/hp_ssh.wasm \
--agent-output /bin/hp_agent \
--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
FROM --platform=$BUILDPLATFORM node:22.16-slim AS js-base
WORKDIR /run
RUN corepack enable
COPY patches ./patches
RUN pnpm install --frozen-lockfile
COPY package.json pnpm-lock.yaml build.sh ./
COPY --from=go-base /bin/hp_ssh.wasm /run/app/hp_ssh.wasm
COPY --from=go-base /bin/wasm_exec.js /run/app/wasm_exec.js
RUN ./build.sh --app --app-install-only
COPY . .
RUN pnpm run build
RUN ./build.sh --app
FROM gcr.io/distroless/nodejs22-debian12:latest AS final
COPY --from=js-base /run/build /app/build
COPY --from=js-base /run/drizzle /app/drizzle
COPY --from=js-base /run/node_modules /app/node_modules
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
COPY --from=go-base /var/lib/headplane /var/lib/headplane
# Fake shell to inform the user that they should use the debug image
COPY --from=go-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"]
FROM node:22-alpine
WORKDIR /app
COPY --from=build /app/build /app/build
CMD [ "node", "./build/server/index.js" ]
CMD [ "/app/build/server/index.js" ]
FROM node:22-alpine AS debug-shell
RUN apk add --no-cache bash curl
COPY --from=js-base /run/build /app/build
COPY --from=js-base /run/drizzle /app/drizzle
COPY --from=js-base /run/node_modules /app/node_modules
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
COPY --from=go-base /var/lib/headplane /var/lib/headplane
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" ]
+16 -19
View File
@@ -4,15 +4,15 @@
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/preview-dark.png"
srcset="./docs/assets/preview-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/preview-light.png"
srcset="./docs/assets/preview-light.png"
>
<img
alt="Preview"
src="./assets/preview-dark.png"
src="./docs/assets/preview-dark.png"
>
</picture>
@@ -32,50 +32,47 @@ These are some of the features that Headplane offers:
- Configurability for Headscale's settings
## Deployment
Headplane runs as a server-based web-application, meaning you'll need a server to run it.
It's available as a Docker image (recommended) or through a manual installation.
There are 2 ways to deploy Headplane:
Refer to the [website](https://headplane.net) for detailed installation instructions.
- ### [Integrated Mode (Recommended)](/docs/Integrated-Mode.md)
Integrated mode unlocks all the features of Headplane and is the most
feature-complete deployment method. It communicates with Headscale directly.
- ### [Simple Mode](/docs/Simple-Mode.md)
Simple mode does not include the automatic management of DNS and Headplane
settings, requiring manual editing and reloading when making changes.
## Versioning
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
Pre-release builds are available under the `next` tag and get updated when a new release
PR is opened and actively in testing.
## Contributing
Headplane is an open-source project and contributions are welcome! If you have
any suggestions, bug reports, or feature requests, please open an issue. Also
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
---
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/acls-dark.png"
srcset="./docs/assets/acls-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/acls-light.png"
srcset="./docs/assets/acls-light.png"
>
<img
alt="ACLs"
src="./assets/acls-dark.png"
src="./docs/assets/acls-dark.png"
>
</picture>
<picture>
<source
media="(prefers-color-scheme: dark)"
srcset="./assets/machine-dark.png"
srcset="./docs/assets/machine-dark.png"
>
<source
media="(prefers-color-scheme: light)"
srcset="./assets/machine-light.png"
srcset="./docs/assets/machine-light.png"
>
<img
alt="Machine Management"
src="./assets/machine-dark.png"
src="./docs/assets/machine-dark.png"
>
</picture>
-15
View File
@@ -1,15 +0,0 @@
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY agent/ ./agent
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-s -w" \
-o /app/hp_agent ./agent/cmd/hp_agent
FROM scratch
COPY --from=builder /app/hp_agent /hp_agent
ENTRYPOINT ["/hp_agent"]
-40
View File
@@ -1,40 +0,0 @@
package main
import (
_ "github.com/joho/godotenv/autoload"
"github.com/tale/headplane/agent/config"
"github.com/tale/headplane/agent/tsnet"
"github.com/tale/headplane/agent/hpagent"
"log"
)
func main() {
cfg, err := config.Load()
if err != nil {
log.Fatalf("Failed to load configuration: %s", err)
}
agent := tsnet.NewAgent(
cfg.Hostname,
cfg.TSControlURL,
cfg.TSAuthKey,
cfg.Debug,
)
agent.StartAndFetchID()
defer agent.Shutdown()
ws, err := hpagent.NewSocket(
agent,
cfg.HPControlURL,
cfg.HPAuthKey,
cfg.Debug,
)
if err != nil {
log.Fatalf("Failed to create websocket: %s", err)
}
defer ws.StopListening()
ws.StartListening()
}
-83
View File
@@ -1,83 +0,0 @@
package hpagent
import (
"encoding/json"
"log"
"sync"
"tailscale.com/tailcfg"
)
// Represents messages from the Headplane master
type RecvMessage struct {
NodeIDs []string `json:omitempty`
}
// Starts listening for messages from the Headplane master
func (s *Socket) StartListening() {
for {
_, message, err := s.ReadMessage()
if err != nil {
log.Printf("error reading message: %v", err)
return
}
var msg RecvMessage
err = json.Unmarshal(message, &msg)
if err != nil {
log.Printf("error unmarshalling message: %v", err)
continue
}
if s.Debug {
log.Printf("got message: %s", message)
}
if len(msg.NodeIDs) == 0 {
log.Printf("got a message with no node IDs? %s", message)
continue
}
// Accumulate the results since we invoke via gofunc
results := make(map[string]*tailcfg.HostinfoView)
mu := sync.Mutex{}
wg := sync.WaitGroup{}
for _, nodeID := range msg.NodeIDs {
wg.Add(1)
go func(nodeID string) {
defer wg.Done()
result, err := s.Agent.GetStatusForPeer(nodeID)
if err != nil {
log.Printf("error getting status: %v", err)
return
}
if result == nil {
return
}
mu.Lock()
results[nodeID] = result
mu.Unlock()
}(nodeID)
}
wg.Wait()
// Send the results back to the Headplane master
err = s.SendStatus(results)
if err != nil {
log.Printf("error sending status: %v", err)
return
}
if s.Debug {
log.Printf("sent status: %s", results)
}
}
}
// Stops listening for messages from the Headplane master
func (s *Socket) StopListening() {
s.Close()
}
-11
View File
@@ -1,11 +0,0 @@
package hpagent
import (
"tailscale.com/tailcfg"
)
// Sends the status to the Headplane master
func (s *Socket) SendStatus(status map[string]*tailcfg.HostinfoView) error {
err := s.WriteJSON(status)
return err
}
-63
View File
@@ -1,63 +0,0 @@
package hpagent
import (
"fmt"
"log"
"net/http"
"net/url"
"github.com/gorilla/websocket"
"github.com/tale/headplane/agent/tsnet"
)
type Socket struct {
*websocket.Conn
Debug bool
Agent *tsnet.TSAgent
}
// Creates a new websocket connection to the Headplane server.
func NewSocket(agent *tsnet.TSAgent, controlURL, authKey string, debug bool) (*Socket, error) {
wsURL, err := httpToWs(controlURL)
if err != nil {
return nil, err
}
headers := http.Header{}
headers.Add("X-Headplane-Tailnet-ID", agent.ID)
auth := fmt.Sprintf("Bearer %s", authKey)
headers.Add("Authorization", auth)
log.Printf("dialing websocket at %s", wsURL)
ws, _, err := websocket.DefaultDialer.Dial(wsURL, headers)
if err != nil {
return nil, err
}
return &Socket{ws, debug, agent}, nil
}
// We need to convert the control URL to a websocket URL
func httpToWs(controlURL string) (string, error) {
u, err := url.Parse(controlURL)
if err != nil {
return "", err
}
if u.Scheme == "http" {
u.Scheme = "ws"
} else if u.Scheme == "https" {
u.Scheme = "wss"
} else {
return "", fmt.Errorf("unsupported scheme: %s", u.Scheme)
}
// We also need to append /_dial to the path
if u.Path[len(u.Path)-1] != '/' {
u.Path += "/"
}
u.Path += "_dial"
return u.String(), nil
}
-47
View File
@@ -1,47 +0,0 @@
package tsnet
import (
"context"
"fmt"
"log"
"strings"
"tailscale.com/tailcfg"
"tailscale.com/types/key"
"go4.org/mem"
)
// Returns the raw hostinfo for a peer based on node ID.
func (s *TSAgent) GetStatusForPeer(id string) (*tailcfg.HostinfoView, error) {
if !strings.HasPrefix(id, "nodekey:") {
return nil, fmt.Errorf("invalid node ID: %s", id)
}
if s.Debug {
log.Printf("querying peer state for %s", id)
}
status, err := s.Lc.Status(context.Background())
if err != nil {
return nil, fmt.Errorf("failed to get status: %w", err)
}
nodeKey, err := key.ParseNodePublicUntyped(mem.S(id[8:]))
peer := status.Peer[nodeKey]
if peer == nil {
// Check if we are on Self.
if status.Self.PublicKey == nodeKey {
peer = status.Self
} else {
return nil, nil
}
}
ip := peer.TailscaleIPs[0].String()
whois, err := s.Lc.WhoIs(context.Background(), ip)
if err != nil {
return nil, fmt.Errorf("failed to get whois: %w", err)
}
return &whois.Node.Hostinfo, nil
}
-61
View File
@@ -1,61 +0,0 @@
package tsnet
import (
"context"
"fmt"
"log"
"os"
"tailscale.com/client/tailscale"
"tailscale.com/tsnet"
)
// Wrapper type so we can add methods to the server.
type TSAgent struct {
*tsnet.Server
Lc *tailscale.LocalClient
ID string
Debug bool
}
// Creates a new tsnet agent and returns an instance of the server.
func NewAgent(hostname, controlURL, authKey string, debug bool) *TSAgent {
s := &tsnet.Server{
Hostname: hostname,
ControlURL: controlURL,
AuthKey: authKey,
Logf: func(string, ...interface{}) {}, // Disabled by default
}
if debug {
s.Logf = log.New(
os.Stderr,
fmt.Sprintf("[DBG:%s] ", hostname),
log.LstdFlags,
).Printf
}
return &TSAgent{s, nil, "", debug}
}
// Starts the tsnet agent and sets the node ID.
func (s *TSAgent) StartAndFetchID() {
// Waits until the agent is up and running.
status, err := s.Up(context.Background())
if err != nil {
log.Fatalf("Failed to start agent: %v", err)
}
s.Lc, err = s.LocalClient()
if err != nil {
log.Fatalf("Failed to create local client: %v", err)
}
log.Printf("Agent running with ID: %s", status.Self.PublicKey)
s.ID = string(status.Self.ID)
}
// Shuts down the tsnet agent.
func (s *TSAgent) Shutdown() {
s.Close()
}
+40 -21
View File
@@ -1,43 +1,54 @@
import { Check, Copy } from 'lucide-react';
import { Check, Copy, Info } from 'lucide-react';
import cn from '~/utils/cn';
import toast from '~/utils/toast';
import Tooltip from './Tooltip';
export interface AttributeProps {
name: string;
value: string;
tooltip?: string;
isCopyable?: boolean;
link?: string;
}
export default function Attribute({
name,
value,
link,
tooltip,
isCopyable,
}: AttributeProps) {
return (
<dl className="flex items-center w-full gap-x-1">
<dt className="font-semibold w-1/4 shrink-0 text-sm">
{link ? (
<a className="hover:underline" href={link}>
{name}
</a>
) : (
name
<dl className="flex gap-1 items-center text-sm">
<dt
className={cn(
'w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0',
'text-headplane-500 dark:text-headplane-400',
tooltip ? 'flex items-center gap-1' : undefined,
)}
>
{name}
{tooltip ? (
<Tooltip>
<Info className="size-4" />
<Tooltip.Body>{tooltip}</Tooltip.Body>
</Tooltip>
) : undefined}
</dt>
<dd
className={cn(
'rounded-lg truncate w-full px-2.5 py-1 text-sm',
'flex items-center gap-x-1',
'focus-within:outline-none focus-within:ring-2',
isCopyable && 'hover:bg-headplane-100 dark:hover:bg-headplane-800',
'min-w-0 px-1.5 py-1 rounded-lg border border-transparent',
...(isCopyable
? [
'cursor-pointer hover:shadow-xs',
'hover:bg-headplane-50 dark:hover:bg-headplane-800',
'hover:border-headplane-100 dark:hover:border-headplane-700',
]
: []),
)}
>
{isCopyable ? (
<button
type="button"
className="w-full flex items-center gap-x-1 outline-none"
className="flex items-center gap-1.5 relative min-w-0 w-full"
onClick={async (event) => {
const svgs = event.currentTarget.querySelectorAll('svg');
for (const svg of svgs) {
@@ -45,7 +56,7 @@ export default function Attribute({
}
await navigator.clipboard.writeText(value);
toast('Copied to clipboard');
toast(`Copied ${name} to clipboard`);
setTimeout(() => {
for (const svg of svgs) {
@@ -54,12 +65,20 @@ export default function Attribute({
}, 1000);
}}
>
<p className="truncate">{value}</p>
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
<div suppressHydrationWarning className="truncate">
{value}
</div>
{isCopyable ? (
<div>
<Check className="size-4 hidden data-copied:block" />
<Copy className="size-4 block data-copied:hidden" />
</div>
) : undefined}
</button>
) : (
value
<div className="relative min-w-0 truncate" suppressHydrationWarning>
{value}
</div>
)}
</dd>
</dl>
+1 -1
View File
@@ -20,7 +20,7 @@ export default function Button({ variant = 'light', ...props }: ButtonProps) {
{...buttonProps}
className={cn(
'w-fit text-sm rounded-xl px-3 py-2',
'focus:outline-none focus:ring',
'focus:outline-hidden focus:ring-3',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
+1 -1
View File
@@ -15,7 +15,7 @@ function Card({ variant = 'raised', ...props }: Props) {
'w-full max-w-md rounded-3xl p-5',
variant === 'flat'
? 'bg-transparent shadow-none'
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-sm',
: 'bg-headplane-50/50 dark:bg-headplane-950/50 shadow-xs',
'border border-headplane-100 dark:border-headplane-800',
props.className,
)}
+2 -2
View File
@@ -17,10 +17,10 @@ export default function Chip({
return (
<span
className={cn(
'text-xs px-2 py-0.5 rounded-full',
'h-5 text-xs py-0.5 px-1 rounded-md text-nowrap',
'text-headplane-700 dark:text-headplane-100',
'bg-headplane-100 dark:bg-headplane-700',
leftIcon || rightIcon ? 'inline-flex items-center gap-x-1' : '',
'inline-flex items-center gap-x-1',
className,
)}
>
+5 -5
View File
@@ -5,7 +5,7 @@ import toast from '~/utils/toast';
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
children: string | string[];
children: string | string[] | number;
}
export default function Code({ isCopyable, children, className }: CodeProps) {
@@ -13,7 +13,7 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
<code
className={cn(
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono',
'rounded-lg focus-within:outline-none focus-within:ring-2',
'rounded-lg focus-within:outline-hidden focus-within:ring-2',
isCopyable && 'relative pr-7',
className,
)}
@@ -21,7 +21,6 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
{children}
{isCopyable && (
<button
type="button"
className="bottom-0 right-0 absolute"
onClick={async (event) => {
const text = Array.isArray(children) ? children.join('') : children;
@@ -40,9 +39,10 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
}
}, 1000);
}}
type="button"
>
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
<Check className="h-4.5 w-4.5 p-1 hidden data-copied:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-copied:hidden" />
</button>
)}
</code>
+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-none 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>
-61
View File
@@ -1,61 +0,0 @@
import { AlertIcon } from '@primer/octicons-react';
import { isRouteErrorResponse, useRouteError } from 'react-router';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
interface Props {
type?: 'full' | 'embedded';
}
function getMessage(error: Error | unknown) {
if (!(error instanceof Error)) {
return 'An unknown error occurred';
}
let rootError = error;
// Traverse the error chain to find the root cause
if (error.cause) {
rootError = error.cause as Error;
while (rootError.cause) {
rootError = rootError.cause as Error;
}
}
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
return rootError.errors.map((error) => error.message).join('\n');
}
return rootError.message;
}
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const message = getMessage(error);
return (
<div
className={cn(
'flex items-center justify-center',
type === 'embedded'
? 'pointer-events-none mt-24'
: 'fixed inset-0 h-screen w-screen z-50',
)}
>
<Card>
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">
{routing ? error.status : 'Error'}
</Card.Title>
<AlertIcon className="w-12 h-12 text-red-500" />
</div>
<Card.Text className="mt-4 text-lg">
{routing ? error.statusText : <Code>{message}</Code>}
</Card.Text>
</Card>
</div>
);
}
+53 -20
View File
@@ -1,38 +1,71 @@
import { CircleX } from 'lucide-react';
import Link from '~/components/Link';
import cn from '~/utils/cn';
interface FooterProps {
url: string;
debug: boolean;
healthy: boolean;
}
export default function Footer({ url, debug }: FooterProps) {
export default function Footer({ url, debug, healthy }: FooterProps) {
return (
<footer
className={cn(
'fixed bottom-0 left-0 z-40 w-full h-14',
'flex flex-col justify-center gap-1 shadow-inner',
'bg-headplane-100 dark:bg-headplane-950',
'text-headplane-800 dark:text-headplane-200',
'fixed w-full bottom-0 left-0 z-40 h-12',
'flex items-center justify-center',
'bg-headplane-50 dark:bg-headplane-950',
'dark:border-t dark:border-headplane-800',
)}
>
<p className="container text-xs">
Headplane is entirely free to use. If you find it useful, consider{' '}
<Link
to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors"
<div
className={cn(
'grid grid-rows-1 items-center container mx-auto',
!healthy && 'md:grid-cols-[1fr_auto] grid-cols-1',
)}
>
<div
className={cn('text-xs leading-none', !healthy && 'hidden md:block')}
>
donating
</Link>{' '}
to support development.{' '}
</p>
<p className="container text-xs opacity-75">
Version: {__VERSION__}
{' — '}
Connecting to <strong className="blur-xs hover:blur-none">{url}</strong>
{debug && ' (Debug mode enabled)'}
</p>
<p>
Headplane is free. Please consider{' '}
<Link
to="https://github.com/sponsors/tale"
name="Aarnav's GitHub Sponsors"
>
donating
</Link>{' '}
to support development.{' '}
</p>
<p className="opacity-75">
Version: {__VERSION__}
{' — '}
Connecting to{' '}
<button
type="button"
tabIndex={0} // Allows keyboard focus
className={cn(
'blur-sm hover:blur-none focus:blur-none transition',
'focus:outline-hidden focus:ring-2 rounded-xs',
)}
>
{url}
</button>
{debug && ' (Debug mode enabled)'}
</p>
</div>
{!healthy ? (
<div
className={cn(
'flex gap-1.5 items-center p-2 rounded-xl text-sm',
'bg-red-500 text-white font-semibold',
)}
>
<CircleX size={16} strokeWidth={3} />
<p className="text-nowrap">Headscale is unreachable</p>
</div>
) : undefined}
</div>
</footer>
);
}
+56 -31
View File
@@ -2,22 +2,29 @@ import {
CircleUser,
Globe2,
Lock,
PlaneTakeoff,
Server,
Settings,
Users,
} from 'lucide-react';
import type { ReactNode } from 'react';
import { NavLink, useSubmit } from 'react-router';
import Logo from '~/components/Logo';
import Menu from '~/components/Menu';
import { AuthSession } from '~/server/web/sessions';
import cn from '~/utils/cn';
interface Props {
configAvailable: boolean;
uiAccess: boolean;
onboarding: boolean;
user?: AuthSession['user'];
access: {
ui: boolean;
machines: boolean;
dns: boolean;
users: boolean;
policy: boolean;
settings: boolean;
};
}
interface LinkProps {
@@ -35,18 +42,18 @@ function TabLink({ name, to, icon }: TabLinkProps) {
return (
<div className="relative py-2">
<NavLink
to={to}
prefetch="intent"
className={({ isActive }) =>
cn(
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5',
'after:absolute after:bottom-0 after:left-3 after:right-3',
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
'hover:bg-headplane-200 dark:hover:bg-headplane-900',
'focus:outline-none focus:ring',
'focus:outline-hidden focus:ring-3',
isActive ? 'after:visible' : 'after:invisible',
)
}
prefetch="intent"
to={to}
>
{icon} {name}
</NavLink>
@@ -57,13 +64,13 @@ function TabLink({ name, to, icon }: TabLinkProps) {
function Link({ href, text }: LinkProps) {
return (
<a
href={href}
target="_blank"
rel="noreferrer"
className={cn(
'hidden sm:block hover:underline text-sm',
'focus:outline-none focus:ring rounded-md',
'focus:outline-hidden focus:ring-3 rounded-md',
)}
href={href}
rel="noreferrer"
target="_blank"
>
{text}
</a>
@@ -84,7 +91,7 @@ export default function Header(data: Props) {
>
<div className="container flex items-center justify-between py-4">
<div className="flex items-center gap-x-2">
<PlaneTakeoff />
<Logo />
<h1 className="text-2xl font-semibold">headplane</h1>
</div>
<div className="flex items-center gap-x-4">
@@ -94,20 +101,21 @@ export default function Header(data: Props) {
{data.user ? (
<Menu>
<Menu.IconButton
label="User"
className={cn(data.user.picture ? 'p-0' : '')}
label="User"
>
{data.user.picture ? (
<img
src={data.user.picture}
alt={data.user.name}
className="w-8 h-8 rounded-full"
src={data.user.picture}
/>
) : (
<CircleUser />
)}
</Menu.IconButton>
<Menu.Panel
disabledKeys={['profile']}
onAction={(key) => {
if (key === 'logout') {
submit(
@@ -119,7 +127,6 @@ export default function Header(data: Props) {
);
}
}}
disabledKeys={['profile']}
>
<Menu.Section>
<Menu.Item key="profile" textValue="Profile">
@@ -137,27 +144,45 @@ export default function Header(data: Props) {
) : undefined}
</div>
</div>
{data.uiAccess && !data.onboarding ? (
{data.access.ui && !data.onboarding ? (
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
<TabLink
to="/machines"
name="Machines"
icon={<Server className="w-5" />}
/>
<TabLink to="/users" name="Users" icon={<Users className="w-5" />} />
<TabLink
to="/acls"
name="Access Control"
icon={<Lock className="w-5" />}
/>
{data.access.machines ? (
<TabLink
icon={<Server className="w-5" />}
name="Machines"
to="/machines"
/>
) : undefined}
{data.access.users ? (
<TabLink
icon={<Users className="w-5" />}
name="Users"
to="/users"
/>
) : undefined}
{data.access.policy ? (
<TabLink
icon={<Lock className="w-5" />}
name="Access Control"
to="/acls"
/>
) : undefined}
{data.configAvailable ? (
<>
<TabLink to="/dns" name="DNS" icon={<Globe2 className="w-5" />} />
<TabLink
to="/settings"
name="Settings"
icon={<Settings className="w-5" />}
/>
{data.access.dns ? (
<TabLink
icon={<Globe2 className="w-5" />}
name="DNS"
to="/dns"
/>
) : undefined}
{data.access.settings ? (
<TabLink
icon={<Settings className="w-5" />}
name="Settings"
to="/settings"
/>
) : undefined}
</>
) : undefined}
</nav>
+1 -1
View File
@@ -25,7 +25,7 @@ export default function IconButton({
aria-label={props.label}
className={cn(
'rounded-full flex items-center justify-center p-1',
'focus:outline-none focus:ring',
'focus:outline-hidden focus:ring-3',
props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy'
? [
+20 -11
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,15 +59,15 @@ export default function Input(props: InputProps) {
</label>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
className={cn(
'rounded-xl px-3 py-2',
'focus:outline-none focus:ring',
'focus:outline-hidden focus:ring-3',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
className,
)}
ref={ref}
required={props.isRequired}
/>
{props.description && (
<div
@@ -71,14 +80,14 @@ export default function Input(props: InputProps) {
{props.description}
</div>
)}
{isInvalid && (
{isInvalid ? (
<div
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
{errorMessage ?? validationErrors.join(' ')}
</div>
)}
) : null}
</div>
);
}
+1 -1
View File
@@ -24,7 +24,7 @@ export default function Link({
'inline-flex items-center gap-x-0.5',
'text-blue-500 hover:text-blue-700',
'dark:text-blue-400 dark:hover:text-blue-300',
'focus:outline-none focus:ring rounded-md',
'focus:outline-hidden focus:ring-3 rounded-md',
className,
)}
>
+10
View File
@@ -0,0 +1,10 @@
import cn from '~/utils/cn';
import LogoSvg from '../../public/logo-light.svg';
export interface LogoProps {
className?: string;
}
export default function Logo({ className }: LogoProps) {
return <img alt="Logo" className={cn(className)} src={LogoSvg} />;
}
+25 -8
View File
@@ -16,6 +16,8 @@ import cn from '~/utils/cn';
interface MenuProps extends MenuTriggerProps {
placement?: Placement;
isDisabled?: boolean;
disabledKeys?: Key[];
children: [
React.ReactElement<ButtonProps> | React.ReactElement<IconButtonProps>,
React.ReactElement<MenuPanelProps>,
@@ -23,8 +25,9 @@ interface MenuProps extends MenuTriggerProps {
}
// TODO: onAction is called twice for some reason?
// TODO: isDisabled per-prop
function Menu(props: MenuProps) {
const { placement = 'bottom' } = props;
const { placement = 'bottom', isDisabled, disabledKeys = [] } = props;
const state = useMenuTriggerState(props);
const ref = useRef<HTMLButtonElement | null>(null);
const { menuTriggerProps, menuProps } = useMenuTrigger<object>(
@@ -40,6 +43,7 @@ function Menu(props: MenuProps) {
<div>
{cloneElement(button, {
...menuTriggerProps,
isDisabled: isDisabled,
ref,
})}
{state.isOpen && (
@@ -48,6 +52,7 @@ function Menu(props: MenuProps) {
...menuProps,
autoFocus: state.focusStrategy ?? true,
onClose: () => state.close(),
disabledKeys,
})}
</Popover>
)}
@@ -57,6 +62,7 @@ function Menu(props: MenuProps) {
interface MenuPanelProps extends AriaMenuProps<object> {
onClose?: () => void;
disabledKeys?: Key[];
}
function Panel(props: MenuPanelProps) {
@@ -68,10 +74,15 @@ function Panel(props: MenuPanelProps) {
<ul
{...menuProps}
ref={ref}
className="pt-1 pb-1 shadow-xs rounded-md min-w-[200px] focus:outline-none"
className="pt-1 pb-1 shadow-2xs rounded-md min-w-[200px] focus:outline-hidden"
>
{[...state.collection].map((item) => (
<MenuSection key={item.key} section={item} state={state} />
<MenuSection
key={item.key}
section={item}
state={state}
disabledKeys={props.disabledKeys}
/>
))}
</ul>
);
@@ -80,9 +91,10 @@ function Panel(props: MenuPanelProps) {
interface MenuSectionProps<T> {
section: Node<T>;
state: TreeState<T>;
disabledKeys?: Key[];
}
function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
function MenuSection<T>({ section, state, disabledKeys }: MenuSectionProps<T>) {
const { itemProps, groupProps } = useMenuSection({
heading: section.rendered,
'aria-label': section['aria-label'],
@@ -106,7 +118,12 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
<li {...itemProps}>
<ul {...groupProps}>
{[...section.childNodes].map((item) => (
<MenuItem key={item.key} item={item} state={state} />
<MenuItem
key={item.key}
item={item}
state={state}
isDisabled={disabledKeys?.includes(item.key)}
/>
))}
</ul>
</li>
@@ -117,14 +134,14 @@ function MenuSection<T>({ section, state }: MenuSectionProps<T>) {
interface MenuItemProps<T> {
item: Node<T>;
state: TreeState<T>;
isDisabled?: boolean;
}
function MenuItem<T>({ item, state }: MenuItemProps<T>) {
function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
const ref = useRef<HTMLLIElement | null>(null);
const { menuItemProps } = useMenuItem({ key: item.key }, state, ref);
const isFocused = state.selectionManager.focusedKey === item.key;
const isDisabled = state.selectionManager.isDisabled(item.key);
return (
<li
@@ -132,7 +149,7 @@ function MenuItem<T>({ item, state }: MenuItemProps<T>) {
ref={ref}
className={cn(
'py-2 px-3 mx-1 rounded-lg',
'focus:outline-none select-none',
'focus:outline-hidden select-none',
isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
isDisabled
? 'text-headplane-400 dark:text-headplane-600'
+34 -5
View File
@@ -1,16 +1,45 @@
import { CircleSlash2 } from 'lucide-react';
import {
CircleAlert,
CircleSlash2,
LucideProps,
TriangleAlert,
} from 'lucide-react';
import React from 'react';
import Card from '~/components/Card';
export interface NoticeProps {
children: React.ReactNode;
title?: string;
variant?: 'default' | 'error' | 'warning';
icon?: React.ReactElement<LucideProps>;
}
export default function Notice({ children }: NoticeProps) {
export default function Notice({
children,
title,
variant,
icon,
}: NoticeProps) {
return (
<Card className="flex w-full max-w-full gap-4 font-semibold">
<CircleSlash2 />
{children}
<Card variant="flat" className="max-w-2xl my-6">
<div className="flex items-center justify-between">
{title ? (
<Card.Title className="text-xl mb-0">{title}</Card.Title>
) : undefined}
{!variant && icon ? icon : iconForVariant(variant)}
</div>
<Card.Text className="mt-4">{children}</Card.Text>
</Card>
);
}
function iconForVariant(variant?: 'default' | 'error' | 'warning') {
switch (variant) {
case 'error':
return <TriangleAlert className="text-red-500" />;
case 'warning':
return <CircleAlert className="text-yellow-500" />;
default:
return <CircleSlash2 />;
}
}
+2 -2
View File
@@ -50,7 +50,7 @@ export default function NumberInput(props: InputProps) {
{...groupProps}
className={cn(
'flex items-center gap-1 rounded-xl pr-1',
'focus-within:outline-none focus-within:ring',
'focus-within:outline-hidden focus-within:ring-3',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
)}
@@ -60,7 +60,7 @@ export default function NumberInput(props: InputProps) {
required={props.isRequired}
ref={ref}
id={id}
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-none"
className="w-full pl-3 py-2 rounded-l-xl bg-transparent focus:outline-hidden"
/>
<input type="hidden" name={name} value={state.numberValue} />
<IconButton
+1 -1
View File
@@ -52,7 +52,7 @@ function Option({ item, state }: OptionsOptionProps) {
className={cn(
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
'focus:outline-none focus:ring z-10',
'focus:outline-hidden focus:ring-3 z-10',
'border border-headplane-100 dark:border-headplane-800',
)}
>
+1 -1
View File
@@ -34,7 +34,7 @@ export default function Popover(props: PopoverProps) {
{...popoverProps}
ref={ref}
className={cn(
'z-10 shadow-sm rounded-xl',
'z-10 shadow-xs rounded-xl',
'bg-white dark:bg-headplane-900',
'border border-headplane-200 dark:border-headplane-800',
className,
+83
View File
@@ -0,0 +1,83 @@
import React, { createContext, useContext, useRef } from 'react';
import {
AriaRadioGroupProps,
AriaRadioProps,
VisuallyHidden,
useFocusRing,
} from 'react-aria';
import { RadioGroupState } from 'react-stately';
import cn from '~/utils/cn';
import { useRadio, useRadioGroup } from 'react-aria';
import { useRadioGroupState } from 'react-stately';
interface RadioGroupProps extends AriaRadioGroupProps {
children: React.ReactElement<RadioProps>[];
label: string;
className?: string;
}
const RadioContext = createContext<RadioGroupState | null>(null);
function RadioGroup({ children, label, className, ...props }: RadioGroupProps) {
const state = useRadioGroupState(props);
const { radioGroupProps, labelProps } = useRadioGroup(
{
...props,
'aria-label': label,
},
state,
);
return (
<div {...radioGroupProps} className={cn('flex flex-col gap-2', className)}>
<VisuallyHidden>
<span {...labelProps}>{label}</span>
</VisuallyHidden>
<RadioContext.Provider value={state}>{children}</RadioContext.Provider>
</div>
);
}
interface RadioProps extends AriaRadioProps {
label: string;
className?: string;
}
function Radio({ children, label, className, ...props }: RadioProps) {
const state = useContext(RadioContext);
const ref = useRef(null);
const { inputProps, isSelected, isDisabled } = useRadio(
{
...props,
'aria-label': label,
},
state!,
ref,
);
const { isFocusVisible, focusProps } = useFocusRing();
return (
<label className="flex items-center gap-2 text-sm">
<VisuallyHidden>
<input {...inputProps} {...focusProps} ref={ref} className="peer" />
</VisuallyHidden>
<div
aria-hidden="true"
className={cn(
'w-5 h-5 aspect-square rounded-full p-1 border-2',
'border border-headplane-600 dark:border-headplane-300',
isFocusVisible ? 'ring-4' : '',
isDisabled ? 'opacity-50 cursor-not-allowed' : '',
isSelected
? 'border-[6px] border-headplane-900 dark:border-headplane-100'
: '',
className,
)}
/>
{children}
</label>
);
}
export default Object.assign(RadioGroup, { Radio });
+20 -18
View File
@@ -50,36 +50,39 @@ function Select(props: SelectProps) {
<div className={cn('flex flex-col', props.className)}>
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
htmlFor={id}
>
{props.label}
</label>
<div
className={cn(
'flex rounded-xl focus:outline-none focus-within:ring',
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
props.isInvalid && 'ring-red-400',
)}
>
<input
{...inputProps}
ref={inputRef}
id={id}
className="outline-none px-3 py-2 rounded-l-xl w-full bg-transparent"
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
data-1p-ignore
id={id}
ref={inputRef}
/>
<button
{...buttonProps}
ref={buttonRef}
className={cn(
'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
props.isDisabled
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)}
ref={buttonRef}
>
<ChevronDown className="p-0.5" />
</button>
@@ -97,12 +100,12 @@ function Select(props: SelectProps) {
)}
{state.isOpen && (
<Popover
popoverRef={popoverRef}
triggerRef={inputRef}
state={state}
className="w-full max-w-xs"
isNonModal
placement="bottom start"
className="w-full max-w-xs"
popoverRef={popoverRef}
state={state}
triggerRef={inputRef}
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
@@ -112,23 +115,22 @@ function Select(props: SelectProps) {
}
interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef?: React.RefObject<HTMLUListElement | null>;
listBoxRef: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
}
function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props;
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null);
const { listBoxProps } = useListBox(props, state, ref);
const { listBoxProps } = useListBox(props, state, listBoxRef);
return (
<ul
{...listBoxProps}
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
ref={listBoxRef}
className="w-full max-h-72 overflow-auto outline-none pt-1"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
<Option item={item} key={item.key} state={state} />
))}
</ul>
);
@@ -152,16 +154,16 @@ function Option({ item, state }: OptionProps) {
return (
<li
{...optionProps}
ref={ref}
className={cn(
'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1',
'focus:outline-none select-none',
'focus:outline-hidden select-none',
isFocused || isSelected
? 'bg-headplane-100/50 dark:bg-headplane-800'
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
isDisabled && 'text-headplane-300 dark:text-headplane-600',
)}
ref={ref}
>
{item.rendered}
{isSelected && <Check className="p-0.5" />}
+5 -2
View File
@@ -11,6 +11,7 @@ import cn from '~/utils/cn';
export interface SwitchProps extends AriaSwitchProps {
label: string;
className?: string;
switchClassName?: string;
}
export default function Switch(props: SwitchProps) {
@@ -45,14 +46,16 @@ export default function Switch(props: SwitchProps) {
state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
isFocusVisible && 'ring-2',
props.isDisabled && 'opacity-50',
props.className,
)}
>
<span
className={cn(
'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-50 ease-in-out',
'translate-x-0 group-selected:translate-x-[100%]',
state.isSelected && 'translate-x-[100%]',
'translate-x-0 group-selected:translate-x-full',
state.isSelected && 'translate-x-full',
props.switchClassName,
)}
/>
</div>
+6 -6
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,14 +52,14 @@ 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',
'focus:outline-none focus:ring z-10',
'focus:outline-hidden focus:ring-3 z-10',
'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>
+2 -2
View File
@@ -38,7 +38,7 @@ function Tooltip(props: TooltipProps) {
{...triggerProps}
className={cn(
'flex items-center justify-center',
'focus:outline-none focus:ring rounded-xl',
'focus:outline-hidden focus:ring-3 rounded-xl',
)}
>
{component}
@@ -65,7 +65,7 @@ function Body({ state, className, ...props }: TooltipBodyProps) {
{...mergeProps(props, tooltipProps)}
className={cn(
'absolute z-50 p-3 top-full mt-1',
'outline-none rounded-3xl text-sm w-48',
'outline-hidden rounded-3xl text-sm w-48',
'bg-white dark:bg-headplane-950',
'text-black dark:text-white',
'shadow-lg dark:shadow-md rounded-xl',
+211
View File
@@ -0,0 +1,211 @@
import { AlertCircle } from 'lucide-react';
import { isRouteErrorResponse } from 'react-router';
import {
isApiError,
isConnectionError,
} from '~/server/headscale/api/error-client';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
import Link from './Link';
export function getErrorMessage(error: Error | unknown): {
title: string;
jsxMessage: React.ReactNode;
} {
if (isRouteErrorResponse(error)) {
if (isApiError(error.data)) {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
title: 'Headscale API Error',
jsxMessage: (
<>
<Card.Text>
There was an error communicating with the Headscale API.
<br />
The server responded with a status code of{' '}
<strong>{statusCode}</strong>, indicating a server-side issue.
Please check the Headscale server status and try again later.
</Card.Text>
{(error.data.data != null || error.data.rawData != null) && (
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{error.data.data != null ? (
<code>{JSON.stringify(error.data.data, null, 2)}</code>
) : (
<code>{error.data.rawData}</code>
)}
</pre>
)}
</>
),
};
}
const authError =
error.data.statusCode === 401 || error.data.statusCode === 403;
return {
title: 'Invalid response from Headscale API',
jsxMessage: (
<>
<Card.Text className="leading-snug">
The Headscale API returned an unexpected response.
{authError ? (
<>
{' '}
The status code indicates an authentication error. Please
verify your API key and Headplane configuration.
</>
) : (
<>
{' '}
You may be using an unsupported version of Headscale or this
may be a bug.
</>
)}
</Card.Text>
<ul className="list-disc list-inside mt-2">
<li>
Request URL: <Code>{requestUrl}</Code>
</li>
<li>
Status Code:{' '}
<Code>
{/* @ts-expect-error */}
{data === null ? (
<>
{statusCode} {rawData}
</>
) : (
<>
{statusCode} {error.statusText}
</>
)}
</Code>
</li>
</ul>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
<code>{JSON.stringify(error.data, null, 2)}</code>
</pre>
</>
),
};
}
if (isConnectionError(error.data)) {
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
return {
title: 'Cannot connect to Headscale API',
jsxMessage: (
<>
<Card.Text className="leading-snug">
Headplane was unable to reach the Headscale API. Please check your
network setup and configuration to ensure Headplane is able to
connect.
</Card.Text>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{requestUrl}
<br />
{errorCode}: {errorMessage}
{extraData != null && (
<>
<br />
<br />
<code>{JSON.stringify(extraData, null, 2)}</code>
</>
)}
</pre>
</>
),
};
}
return {
title: `Error ${error.status}`,
jsxMessage: (
<>
There was an error processing your request.
<br />
Status Code: <strong>{error.status}</strong>
<br />
Status Text: <strong>{error.data}</strong>
</>
),
};
}
if (!(error instanceof Error)) {
return {
title: 'Unexpected Error',
jsxMessage: (
<>
<Card.Text>
An unexpected error occurred which is most likely a bug. Please
consider reporting filing an issue on the{' '}
<Link
name="Headplane GitHub"
to="https://github.com/tale/headplane/issues"
>
Headplane GitHub
</Link>{' '}
repository with the details below.
</Card.Text>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
<code>{JSON.stringify(error, null, 2)}</code>
</pre>
</>
),
};
}
// Traverse the error chain to find the root cause
let rootError = error;
if (error.cause != null) {
rootError = error.cause as Error;
while (rootError.cause != null) {
rootError = rootError.cause as Error;
}
}
// TODO: If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
throw new Error('AggregateError handling not implemented yet');
}
return {
title:
rootError.name.length > 0 && rootError.name !== 'Error'
? `Error: ${rootError.name}`
: 'Error',
jsxMessage: rootError.message,
};
}
interface ErrorBannerProps {
error: unknown;
className?: string;
}
export function ErrorBanner({ error, className }: ErrorBannerProps) {
const { title, jsxMessage } = getErrorMessage(error);
return (
<Card className={cn('w-screen', className)} variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>{title}</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{jsxMessage}
</Card>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { Info } from 'lucide-react';
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export interface ExitNodeTagProps {
isEnabled?: boolean;
}
export function ExitNodeTag({ isEnabled }: ExitNodeTagProps) {
return (
<Tooltip>
<Chip
text="Exit Node"
className={cn(
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
)}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<>This machine is acting as an exit node.</>
) : (
<>
This machine is requesting to be used as an exit node. Review this
from the "Edit route settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
</Tooltip>
);
}
+42
View File
@@ -0,0 +1,42 @@
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export interface ExpiryTagProps {
variant: 'expired' | 'no-expiry';
expiry?: string;
}
export function ExpiryTag({ variant, expiry }: ExpiryTagProps) {
const formatter = new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
return (
<Tooltip>
<Chip
text={
variant === 'expired'
? `Expired ${formatter.format(new Date(expiry!))}`
: 'No expiry'
}
className="bg-headplane-200 text-headplane-800 dark:bg-headplane-800 dark:text-headplane-200"
/>
<Tooltip.Body>
{variant === 'expired' ? (
<>
This machine is expired and will not be able to connect to the
network. Re-authenticate with Tailscale on the machine to re-enable
it.
</>
) : (
<>
This machine has key expiry disabled and will never need to
re-authenticate.
</>
)}
</Tooltip.Body>
</Tooltip>
);
}
+20
View File
@@ -0,0 +1,20 @@
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export function HeadplaneAgentTag() {
return (
<Tooltip>
<Chip
text="Headplane Agent"
className={cn(
'bg-purple-300 text-purple-900 dark:bg-purple-900 dark:text-purple-300',
)}
/>
<Tooltip.Body>
This machine is running the Headplane agent, which allows it to provide
host information in the web UI.
</Tooltip.Body>
</Tooltip>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { Info } from 'lucide-react';
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export interface SubnetTagProps {
isEnabled?: boolean;
}
export function SubnetTag({ isEnabled }: SubnetTagProps) {
return (
<Tooltip>
<Chip
text="Subnets"
className={cn(
'bg-blue-300 text-blue-900 dark:bg-blue-900 dark:text-blue-300',
)}
rightIcon={isEnabled ? undefined : <Info className="h-full w-fit" />}
/>
<Tooltip.Body>
{isEnabled ? (
<>This machine advertises subnet routes.</>
) : (
<>
This machine has unadvertised subnet routes. Review this from the
"Edit route settings..." option in the machine's menu.
</>
)}
</Tooltip.Body>
</Tooltip>
);
}
+21
View File
@@ -0,0 +1,21 @@
import cn from '~/utils/cn';
import Chip from '../Chip';
import Tooltip from '../Tooltip';
export function TailscaleSSHTag() {
return (
<Tooltip>
<Chip
text="Tailscale SSH"
className={cn(
'bg-lime-500 text-lime-900 dark:bg-lime-900 dark:text-lime-500',
)}
/>
<Tooltip.Body>
This machine advertises Tailscale SSH, which allows you to authenticate
SSH credentials using your Tailscale account and via the Headplane web
UI.
</Tooltip.Body>
</Tooltip>
);
}
-6
View File
@@ -2,12 +2,6 @@ import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
if (import.meta.env.DEV) {
import('react-scan').then(({ scan }) => {
scan({ enabled: true });
});
}
startTransition(() => {
hydrateRoot(
document,
+54 -55
View File
@@ -1,66 +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 { AppLoadContext, EntryContext, 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);
});
}
+29 -43
View File
@@ -1,29 +1,30 @@
import { XCircleFillIcon } from '@primer/octicons-react';
import { type LoaderFunctionArgs, redirect } from 'react-router';
import { Outlet, useLoaderData } from 'react-router';
import type { LoadContext } from '~/server';
import { ResponseError } from '~/server/headscale/api-client';
import cn from '~/utils/cn';
import { Outlet, redirect } from 'react-router';
import { ErrorBanner } from '~/components/error-banner';
import { pruneEphemeralNodes } from '~/server/db/pruner';
import { isDataUnauthorizedError } from '~/server/headscale/api/error-client';
import log from '~/utils/log';
import type { Route } from './+types/dashboard';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck();
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const api = context.hsApi.getRuntimeClient(session.api_key);
// We shouldn't session invalidate if Headscale is down
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
// MARK: The session should stay valid if Headscale isn't healthy
const healthy = await api.isHealthy();
if (healthy) {
try {
await context.client.get('v1/apikey', session.get('api_key')!);
await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest });
} catch (error) {
if (error instanceof ResponseError) {
log.debug('api', 'API Key validation failed %o', error);
if (isDataUnauthorizedError(error)) {
log.warn(
'auth',
'Logging out %s due to expired API key',
session.user.name,
);
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
'Set-Cookie': await context.sessions.destroySession(),
},
});
}
@@ -36,32 +37,17 @@ export async function loader({
}
export default function Layout() {
const { healthy } = useLoaderData<typeof loader>();
return (
<>
{!healthy ? (
<div
className={cn(
'fixed bottom-0 right-0 z-50 w-fit h-14',
'flex flex-col justify-center gap-1',
)}
>
<div
className={cn(
'flex items-center gap-1.5 mr-1.5 py-2 px-1.5',
'border rounded-lg text-white bg-red-500',
'border-red-600 dark:border-red-400 shadow-sm',
)}
>
<XCircleFillIcon className="w-4 h-4 text-white" />
Headscale is unreachable
</div>
</div>
) : undefined}
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
</>
<main className="container mx-auto overscroll-contain mt-4 mb-24">
<Outlet />
</main>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="w-fit mx-auto overscroll-contain my-24">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
}
+59 -80
View File
@@ -1,108 +1,77 @@
import { BanIcon, CircleCheckIcon } from 'lucide-react';
import {
LoaderFunctionArgs,
Outlet,
redirect,
useLoaderData,
} from 'react-router';
import { eq } from 'drizzle-orm';
import { CircleCheckIcon } from 'lucide-react';
import { Outlet, redirect } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import type { LoadContext } from '~/server';
import { users } from '~/server/db/schema';
import { Capabilities } from '~/server/web/roles';
import { User } from '~/types';
import log from '~/utils/log';
import toast from '~/utils/toast';
import { Route } from './+types/shell';
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
// There is a session, but it's not valid
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
if (
typeof context.oidc === 'object' &&
session.user.subject !== 'unknown-non-oauth' &&
!request.url.endsWith('/onboarding')
) {
const [user] = await context.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
// Onboarding is only a feature of the OIDC flow
if (context.oidc && !request.url.endsWith('/onboarding')) {
let onboarded = false;
try {
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
if (users.length === 0) {
onboarded = false;
}
const user = users.find((u) => {
if (u.provider !== 'oidc') {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
return subject === sessionUser.subject;
});
if (user) {
onboarded = true;
}
} catch (e) {
// If we cannot lookup users, just assume our user is onboarded
log.debug('api', 'Failed to lookup users %o', e);
onboarded = true;
}
if (!onboarded) {
if (!user?.onboarded) {
return redirect('/onboarding');
}
}
const api = context.hsApi.getRuntimeClient(session.api_key);
const check = await context.sessions.check(request, Capabilities.ui_access);
return {
config: context.hs.c,
url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(),
debug: context.config.debug,
user: session.get('user'),
user: session.user,
uiAccess: check,
access: {
ui: await context.sessions.check(request, Capabilities.ui_access),
dns: await context.sessions.check(request, Capabilities.read_network),
users: await context.sessions.check(request, Capabilities.read_users),
policy: await context.sessions.check(request, Capabilities.read_policy),
machines: await context.sessions.check(
request,
Capabilities.read_machines,
),
settings: await context.sessions.check(
request,
Capabilities.read_feature,
),
},
onboarding: request.url.endsWith('/onboarding'),
healthy: await api.isHealthy(),
};
} catch {
// No session, so we can just return
return redirect('/login');
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroySession(),
},
});
}
}
export default function Shell() {
const data = useLoaderData<typeof loader>();
export default function Shell({ loaderData }: Route.ComponentProps) {
return (
<>
<Header {...data} />
{data.uiAccess ? (
<Header {...loaderData} />
{/* Always show the outlet if we are onboarding */}
{(loaderData.onboarding ? true : loaderData.uiAccess) ? (
<Outlet />
) : (
<Card className="mx-auto w-fit mt-24">
@@ -114,18 +83,28 @@ export default function Shell() {
Connect to Tailscale with your devices to access this Tailnet. Use
this command to help you get started:
</Card.Text>
<Button className="pointer-events-none text-md hover:bg-initial focus:ring-0">
<Code className="pointer-events-auto bg-transparent" isCopyable>
tailscale up --login-server={data.url}
</Code>
<Button
className="flex text-md font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server=${loaderData.url}`,
);
toast('Copied to clipboard');
}}
>
tailscale up --login-server={loaderData.url}
</Button>
<p className="text-xs mt-1 opacity-50 text-center">
Click this button to copy the command.
</p>
<p className="mt-4 text-sm opacity-50">
Your account does not have access to the UI. Please contact your
administrator if you believe this is a mistake.
</p>
</Card>
)}
<Footer {...data} />
<Footer {...loaderData} />
</>
);
}
+6
View File
@@ -0,0 +1,6 @@
{
"0.26.1": ["0.26.0", "0.26.1"],
"0.27.0": ["0.27.0"],
"0.27.1": ["0.27.1"],
"0.28.0": ["0.28.0"]
}
+135
View File
@@ -0,0 +1,135 @@
{
"0.26.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.26.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.28.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "b10ca7d2750405b2",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "8428b44e3a821e9e",
"DELETE /api/v1/preauthkey": "f05ea1bc8ad89a09",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
}
}
+14 -12
View File
@@ -1,8 +1,4 @@
import type {
LinksFunction,
LoaderFunctionArgs,
MetaFunction,
} from 'react-router';
import type { LinksFunction, MetaFunction } from 'react-router';
import {
Links,
Meta,
@@ -12,14 +8,14 @@ import {
useNavigation,
} from 'react-router';
import '@fontsource-variable/inter';
import { ErrorPopup } from '~/components/Error';
import { ExternalScripts } from 'remix-utils/external-scripts';
import ProgressBar from '~/components/ProgressBar';
import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url';
import { LiveDataProvider } from '~/utils/live-data';
import { useToastQueue } from '~/utils/toast';
import { LoadContext } from './server';
import log from './utils/log';
import type { Route } from './+types/root';
import { ErrorBanner } from './components/error-banner';
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
@@ -44,23 +40,29 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta content="width=device-width, initial-scale=1" name="viewport" />
<Meta />
<Links />
<link 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 />
<Scripts />
<ExternalScripts />
</body>
</html>
</LiveDataProvider>
);
}
export function ErrorBoundary() {
return <ErrorPopup />;
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="w-screen h-screen flex items-center justify-center p-4">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
}
export default function App() {
+9 -3
View File
@@ -5,16 +5,21 @@ 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.tsx'),
route('/login', 'routes/auth/login/page.tsx'),
route('/logout', 'routes/auth/logout.ts'),
route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'),
route('/ssh', 'routes/ssh/console.tsx'),
// All the main logged-in dashboard routes
// Double nested to separate error propagations
layout('layouts/shell.tsx', [
route('/onboarding', 'routes/users/onboarding.tsx'),
route('/onboarding/skip', 'routes/users/onboarding-skip.tsx'),
layout('layouts/dashboard.tsx', [
...prefix('/machines', [
index('routes/machines/overview.tsx'),
@@ -22,12 +27,13 @@ export default [
]),
route('/users', 'routes/users/overview.tsx'),
route('/acls', 'routes/acls/editor.tsx'),
route('/acls', 'routes/acls/overview.tsx'),
route('/dns', 'routes/dns/overview.tsx'),
...prefix('/settings', [
index('routes/settings/overview.tsx'),
route('/auth-keys', 'routes/settings/auth-keys.tsx'),
route('/auth-keys', 'routes/settings/auth-keys/overview.tsx'),
route('/restrictions', 'routes/settings/restrictions/overview.tsx'),
// route('/local-agent', 'routes/settings/local-agent.tsx'),
]),
]),
+139
View File
@@ -0,0 +1,139 @@
import { data } from 'react-router';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
// We only check capabilities here and assume it is writable
// If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening.
export async function aclAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_policy,
);
if (!check) {
throw data('You do not have permission to write to the ACL policy', {
status: 403,
});
}
// Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData();
const policyData = formData.get('policy')?.toString();
if (!policyData) {
throw data('Missing `policy` in the form data.', {
status: 400,
});
}
const api = context.hsApi.getRuntimeClient(session.api_key);
try {
const { policy, updatedAt } = await api.setPolicy(policyData);
return data({
success: true,
error: undefined,
policy,
updatedAt,
});
} catch (error) {
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 });
}
const message =
error.data.data != null &&
'message' in error.data.data &&
typeof error.data.data.message === 'string'
? error.data.data.message
: undefined;
if (message == null) {
throw error;
}
// 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,
);
}
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,
);
}
}
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}
+48
View File
@@ -0,0 +1,48 @@
import { data } from 'react-router';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
// The logic for deciding policy factors is very complicated because
// there are so many factors that need to be accounted for:
// 1. Does the user have permission to read the policy?
// 2. Does the user have permission to write to the policy?
// 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available.
export async function aclLoader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_policy);
if (!check) {
throw data('You do not have permission to read the ACL policy.', {
status: 403,
});
}
const flags = {
// Can the user write to the ACL policy
access: await context.sessions.check(request, Capabilities.write_policy),
writable: false,
policy: '',
};
// Try to load the ACL policy from the API.
const api = context.hsApi.getRuntimeClient(session.api_key);
try {
const { policy, updatedAt } = await api.getPolicy();
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
} catch (error) {
if (isDataWithApiError(error)) {
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
if (error.data.rawData.includes('acl policy not found')) {
flags.policy = '';
flags.writable = true;
return flags;
}
}
throw error;
}
}
+9 -6
View File
@@ -14,6 +14,7 @@ interface EditorProps {
onChange: (value: string) => void;
}
// TODO: Remove ClientOnly
export function Editor(props: EditorProps) {
const [light, setLight] = useState(false);
useEffect(() => {
@@ -37,12 +38,14 @@ export function Editor(props: EditorProps) {
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => (
<CodeMirror
value={props.value}
height="100%"
extensions={[shopify.jsonc()]}
editable={!props.isDisabled}
extensions={[shopify.jsonc()]} // Allow editing unless disabled
height="100%" // Use readOnly if disabled
onChange={(value) => props.onChange(value)}
readOnly={props.isDisabled}
style={{ height: '100%' }}
theme={light ? xcodeLight : xcodeDark}
onChange={(value) => props.onChange(value)}
value={props.value}
/>
)}
</ClientOnly>
@@ -89,14 +92,14 @@ export function Differ(props: DifferProps) {
{() => (
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
<Merge.Original
extensions={[shopify.jsonc()]}
readOnly
value={props.left}
extensions={[shopify.jsonc()]}
/>
<Merge.Modified
extensions={[shopify.jsonc()]}
readOnly
value={props.right}
extensions={[shopify.jsonc()]}
/>
</Merge>
)}
-25
View File
@@ -1,25 +0,0 @@
import { AlertIcon } from '@primer/octicons-react';
import cn from '~/utils/cn';
import Card from '~/components/Card';
import Code from '~/components/Code';
interface Props {
message: string;
}
export function ErrorView({ message }: Props) {
return (
<Card variant="flat" className="max-w-full mb-4">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">Error</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Could not apply changes to your ACL policy due to the following error:
<br />
<Code>{message}</Code>
</Card.Text>
</Card>
);
}
+1 -2
View File
@@ -1,4 +1,3 @@
import Spinner from '~/components/Spinner';
import cn from '~/utils/cn';
interface Props {
@@ -24,12 +23,12 @@ export default function Fallback({ acl }: Props) {
/>
</div>
<textarea
readOnly
className={cn(
'w-full h-editor font-mono resize-none text-sm',
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
'pl-1 pt-1 leading-snug',
)}
readOnly
value={acl}
/>
</div>
@@ -1,39 +0,0 @@
import { AlertIcon } from '@primer/octicons-react';
import cn from '~/utils/cn';
import Card from '~/components/Card';
import Code from '~/components/Code';
interface Props {
mode: 'file' | 'database';
}
export function Unavailable({ mode }: Props) {
return (
<Card variant="flat" className="max-w-prose mt-12">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">ACL Policy Unavailable</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Unable to load a valid ACL policy configuration. This is most likely due
to a misconfiguration in your Headscale configuration file.
</Card.Text>
{mode !== 'file' ? (
<p className="mt-4 text-sm">
According to your configuration, the ACL policy mode is set to{' '}
<Code>file</Code> but the ACL file is not available. Ensure that the{' '}
<Code>policy.path</Code> is set to a valid path in your Headscale
configuration.
</p>
) : (
<p className="mt-4 text-sm">
In order to fully utilize the ACL management features of Headplane,
please set <Code>policy.mode</Code> to either <Code>file</Code> or{' '}
<Code>database</Code> in your Headscale configuration.
</p>
)}
</Card>
);
}
-305
View File
@@ -1,305 +0,0 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import {
redirect,
useFetcher,
useLoaderData,
useRevalidator,
} from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Spinner from '~/components/Spinner';
import Tabs from '~/components/Tabs';
import type { LoadContext } from '~/server';
import { ResponseError } from '~/server/headscale/api-client';
import log from '~/utils/log';
import { send } from '~/utils/res';
import toast from '~/utils/toast';
import { Differ, Editor } from './components/cm.client';
import { ErrorView } from './components/error';
import { Unavailable } from './components/unavailable';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
// The way policy is handled in 0.23 of Headscale and later is verbose.
// The 2 ACL policy modes are either the database one or file one
//
// File: The ACL policy is readonly to the API and manually edited
// Database: The ACL policy is read/write to the API
//
// To determine if we first have an ACL policy available we need to check
// if fetching the v1/policy route gives us a 500 status code or a 200.
//
// 500 can mean many different things here unfortunately:
// - In file based that means the file is not accessible
// - In database mode this can mean that we have never set an ACL policy
// - In database mode this can mean that the ACL policy is not available
// - A general server error may have occurred
//
// Unfortunately the server errors are not very descriptive so we have to
// do some silly guesswork here. If we are running in an integration mode
// and have the Headscale configuration available to us, our assumptions
// can be more accurate, otherwise we just HAVE to assume that the ACL
// policy has never been set.
//
// We can do damage control by checking for write access and if we are not
// able to PUT an ACL policy on the v1/policy route, we can already know
// that the policy is at the very-least readonly or not available.
let modeGuess = 'database'; // Assume database mode
if (!context.hs.readable()) {
modeGuess = context.hs.c!.policy?.mode ?? 'database';
}
// Attempt to load the policy, for both the frontend and for checking
// if we are able to write to the policy for write access
try {
const { policy } = await context.client.get<{ policy: string }>(
'v1/policy',
session.get('api_key')!,
);
let write = false; // On file mode we already know it's readonly
if (modeGuess === 'database' && policy.length > 0) {
try {
await context.client.put('v1/policy', session.get('api_key')!, {
policy: policy,
});
write = true;
} catch (error) {
write = false;
log.debug('api', 'Failed to write to ACL policy with error %s', error);
}
}
return {
read: true,
write,
mode: modeGuess,
policy,
};
} catch {
// If we are explicit on file mode then this is the end of the road
if (modeGuess === 'file') {
return {
read: false,
write: false,
mode: modeGuess,
policy: null,
};
}
// Assume that we have write access otherwise?
// This is sort of a brittle assumption to make but we don't want
// to create a default policy if we don't have to.
return {
read: true,
write: true,
mode: modeGuess,
policy: null,
};
}
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
try {
const { acl } = (await request.json()) as { acl: string };
const { policy } = await context.client.put<{ policy: string }>(
'v1/policy',
session.get('api_key')!,
{
policy: acl,
},
);
return { success: true, policy, error: null };
} catch (error) {
log.debug('api', 'Failed to update ACL policy with error %s', error);
// @ts-ignore: TODO: Shut UP we know it's a string most of the time
const text = JSON.parse(error.message);
return send(
{ success: false, error: text.message },
{
status: error instanceof ResponseError ? error.status : 500,
},
);
}
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const fetcher = useFetcher<typeof action>();
const revalidator = useRevalidator();
const [acl, setAcl] = useState(data.policy ?? '');
const [toasted, setToasted] = useState(false);
useEffect(() => {
if (!fetcher.data || toasted) {
return;
}
if (fetcher.data.success) {
toast('Updated tailnet ACL policy');
} else {
toast('Failed to update tailnet ACL policy');
}
setToasted(true);
if (revalidator.state === 'idle') {
revalidator.revalidate();
}
}, [fetcher.data, toasted, data.policy]);
// The state for if the save and discard buttons should be disabled
// is pretty complicated to calculate and varies on different states.
const disabled = useMemo(() => {
if (!data.read || !data.write) {
return true;
}
// First check our fetcher states
if (fetcher.state === 'loading') {
return true;
}
if (revalidator.state === 'loading') {
return true;
}
// If we have a failed fetcher state allow the user to try again
if (fetcher.data?.success === false) {
return false;
}
return data.policy === acl;
}, [data, revalidator.state, fetcher.state, fetcher.data, data.policy, acl]);
return (
<div>
{data.read && !data.write ? (
<div className="mb-4">
<Notice>
The ACL policy is read-only. You can view the current policy but you
cannot make changes to it.
<br />
To resolve this, you need to set the ACL policy mode to database in
your Headscale configuration.
</Notice>
</div>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.success === false ? (
<ErrorView message={fetcher.data.error} />
) : undefined}
{data.read ? (
<>
<Tabs label="ACL Editor" className="mb-4">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor isDisabled={!data.write} value={acl} onChange={setAcl} />
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={data?.policy ?? ''} right={acl} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="w-1/2 text-center mt-4">
Previewing rules is not available yet. This feature is still
in development and is pretty complicated to implement.
Hopefully I will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={disabled}
onPress={() => {
setToasted(false);
fetcher.submit(
{
acl,
},
{
method: 'PATCH',
encType: 'application/json',
},
);
}}
>
{fetcher.state === 'idle' ? undefined : (
<Spinner className="w-3 h-3" />
)}
Save
</Button>
<Button
isDisabled={disabled}
onPress={() => {
setAcl(data?.policy ?? '');
}}
>
Discard Changes
</Button>
</>
) : (
<Unavailable mode={data.mode as 'database' | 'file'} />
)}
</div>
);
}
+220
View File
@@ -0,0 +1,220 @@
import {
AlertCircle,
Construction,
Eye,
FlaskConical,
Pencil,
} from 'lucide-react';
import { useEffect, useState } from 'react';
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';
import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client';
export const loader = aclLoader;
export const action = aclAction;
export default function Page({
loaderData: { access, writable, policy },
}: Route.ComponentProps) {
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
setCodePolicy(policy);
}
}, [policy]);
useEffect(() => {
if (!fetcher.data) {
// No data yet, return
return;
}
if (fetcher.data.success === true) {
toast('Updated policy');
revalidate();
}
}, [fetcher.data]);
return (
<div>
{!access ? (
<Notice title="ACL Policy restricted" variant="warning">
You do not have the necessary permissions to edit the Access Control
List policy. Please contact your administrator to request access or to
make changes to the ACL policy.
</Notice>
) : !writable ? (
<Notice title="Read-only ACL Policy" variant="error">
The ACL policy mode is most likely set to <Code>file</Code> in your
Headscale configuration. This means that the ACL file cannot be edited
through the web interface. In order to resolve this, you'll need to
set <Code>policy.mode</Code> to <Code>database</Code> in your
Headscale configuration.
</Notice>
) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
<p className="mb-4 max-w-prose">
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
name="Tailscale ACL documentation"
to="https://tailscale.com/kb/1018/acls"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
name="Headscale ACL documentation"
to="https://headscale.net/stable/ref/acls/"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.error !== undefined ? (
<Notice
title={fetcher.data.error.split(':')[0] ?? 'Error'}
variant="error"
>
{fetcher.data.error.split(':').slice(1).join(': ') ??
'An unknown error occurred while trying to update the ACL policy.'}
</Notice>
) : undefined}
<Tabs className="mb-4" label="ACL Editor">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor
isDisabled={disabled}
onChange={setCodePolicy}
value={codePolicy}
/>
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={policy} right={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="w-1/2 text-center mt-4">
Previewing rules is not available yet. This feature is still in
development and is pretty complicated to implement. Hopefully I
will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</Tabs>
<Button
className="mr-2"
isDisabled={
disabled ||
fetcher.state !== 'idle' ||
codePolicy.length === 0 ||
codePolicy === policy
}
onPress={() => {
const formData = new FormData();
formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' });
}}
variant="heavy"
>
Save
</Button>
<Button
isDisabled={
disabled || fetcher.state !== 'idle' || codePolicy === policy
}
onPress={() => {
// Reset the editor to the original policy
setCodePolicy(policy);
}}
>
Discard Changes
</Button>
</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;
}
-149
View File
@@ -1,149 +0,0 @@
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import { Form, useActionData, useLoaderData } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import type { LoadContext } from '~/server';
import type { Key } from '~/types';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try {
const session = await context.sessions.auth(request);
if (session.has('api_key')) {
return redirect('/machines');
}
} catch {}
const disableApiKeyLogin = context.config.oidc?.disable_api_key_login;
if (context.oidc && disableApiKeyLogin) {
return redirect('/oidc/start');
}
return {
oidc: context.oidc,
disableApiKeyLogin,
};
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const formData = await request.formData();
const oidcStart = formData.get('oidc-start');
const session = await context.sessions.getOrCreate(request);
if (oidcStart) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
return redirect('/oidc/start');
}
const apiKey = String(formData.get('api-key'));
// Test the API key
try {
const apiKeys = await context.client.get<{ apiKeys: Key[] }>(
'v1/apikey',
apiKey,
);
const key = apiKeys.apiKeys.find((k) => apiKey.startsWith(k.prefix));
if (!key) {
return {
error: 'Invalid API key',
};
}
const expiry = new Date(key.expiration);
const expiresIn = expiry.getTime() - Date.now();
const expiresDays = Math.round(expiresIn / 1000 / 60 / 60 / 24);
session.set('state', 'auth');
session.set('api_key', apiKey);
session.set('user', {
subject: 'unknown-non-oauth',
name: key.prefix,
email: `${expiresDays.toString()} days`,
});
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.commit(session, {
maxAge: expiresIn,
}),
},
});
} catch {
return {
error: 'Invalid API key',
};
}
}
export default function Page() {
const data = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>Welcome to Headplane</Card.Title>
{!data.disableApiKeyLogin ? (
<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>
{actionData?.error ? (
<p className="text-red-500 text-sm mb-2">{actionData.error}</p>
) : undefined}
<Input
isRequired
labelHidden
label="API Key"
name="api-key"
placeholder="API Key"
type="password"
className="mt-4 mb-2"
/>
<Button className="w-full" variant="heavy" type="submit">
Sign In
</Button>
</Form>
) : undefined}
{data.oidc ? (
<Form method="POST">
{data.disableApiKeyLogin ? (
<Card.Text className="mb-6">
Sign in with your authentication provider to continue. Your
administrator has disabled API key login.
</Card.Text>
) : undefined}
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full mt-2"
variant={data.disableApiKeyLogin ? 'heavy' : 'light'}
type="submit"
>
Single Sign-On
</Button>
</Form>
) : undefined}
</Card>
</div>
);
}
+102
View File
@@ -0,0 +1,102 @@
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;
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 });
}
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
//
// 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 });
}
const expiry = new Date(lookup.expiration);
if (expiry.getTime() < Date.now()) {
return {
success: false,
message: "API key has expired",
};
}
const expiresDays = Math.round((expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24);
return redirect("/machines", {
headers: {
"Set-Cookie": await context.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)",
};
}
}
+131
View File
@@ -0,0 +1,131 @@
import { AlertCircle, CloudOff } from "lucide-react";
import Card from "~/components/Card";
import Code from "~/components/Code";
import Link from "~/components/Link";
import { OidcConnectorError } from "~/server/web/oidc-connector";
export function OidcDiscoveryFailedNotice() {
return (
<Card className="m-4 mb-4 max-w-md border border-yellow-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-yellow-500">SSO Temporarily Unavailable</Card.Title>
<CloudOff className="mb-2 h-6 w-6 text-yellow-500" />
</div>
<Card.Text className="text-sm">
Unable to reach the identity provider. Single Sign-On will be available once the provider is
reachable again. You can still sign in with an API key.
</Card.Text>
</Card>
);
}
export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[] }) {
return (
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Authentication Error</Card.Title>
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
</div>
<Card.Text className="text-sm">
The OpenID Connect (OIDC) Single Sign-On (SSO) configuration has issues:{" "}
<ul className="mt-2 mb-1 list-inside list-disc">
{mapOidcErrorsToMessages(errors).map((code) => (
<li key={code.key}>{code.node}</li>
))}
</ul>{" "}
<Link
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#troubleshooting"
>
Learn more
</Link>
</Card.Text>
</Card>
);
}
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
const messages: {
key: string;
node: React.ReactNode;
}[] = [];
for (const error of errors) {
switch (error) {
case "INVALID_API_KEY":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The provided API key for OIDC authentication is invalid. Ensure that{" "}
<Code>oidc.headscale_api_key</Code> is a valid API key.
</Card.Text>
),
});
break;
case "MISSING_AUTHORIZATION_ENDPOINT":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>authorization_endpoint</Code>.
Ensure discovery URL or manual configuration is correct.
</Card.Text>
),
});
break;
case "MISSING_TOKEN_ENDPOINT":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>token_endpoint</Code>. Ensure
discovery URL or manual configuration is correct.
</Card.Text>
),
});
break;
case "MISSING_USERINFO_ENDPOINT":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured <Code>user_endpoint</Code>. Ensure
discovery URL or manual configuration is correct.
</Card.Text>
),
});
break;
case "MISSING_REQUIRED_CLAIMS":
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provider does not support the <Code>sub</Code> claim, which is required for
authentication. Your OIDC provider may be misconfigured.
</Card.Text>
),
});
break;
case "UNKNOWN_ERROR":
messages.push({
key: error,
node: (
<Card.Text className="inline">
An unknown error occurred during OIDC configuration. Please check the Headplane logs
for more information.
</Card.Text>
),
});
break;
}
}
return messages;
}
+15
View File
@@ -0,0 +1,15 @@
import Card from '~/components/Card';
export default function Logout() {
return (
<div className="flex w-screen h-screen items-center justify-center">
<Card className="max-w-md m-4 sm:m-0">
<Card.Title>You have been logged out</Card.Title>
<Card.Text>
You can now close this window. If you would like to log in again,
please refresh the page.
</Card.Text>
</Card>
</div>
);
}
+63
View File
@@ -0,0 +1,63 @@
import { AlertCircle } from 'lucide-react';
import Card from '~/components/Card';
import Code from '~/components/Code';
export function OidcErrorNotice({ code }: { code: string }) {
return (
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Configuration Issue(s)</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{getErrorMessage(code)}
</Card>
);
}
function getErrorMessage(code: string) {
switch (code) {
case 'error_no_query':
return (
<Card.Text>
The SSO provider did not correctly redirect back to Headplane with the
required parameters. Please ensure your SSO provider is configured
correctly.
</Card.Text>
);
case 'error_no_session':
case 'error_invalid_session':
return (
<Card.Text>
Unable to complete SSO login due to missing or invalid session data.
Ensure that your Headplane cookie configuration is correct and that
your browser is accepting cookies.
</Card.Text>
);
case 'error_no_sub':
return (
<Card.Text>
The SSO provider did not return a valid user identifier. Please ensure
your SSO provider is correctly configured to provide the{' '}
<Code>sub</Code> claim.
</Card.Text>
);
case 'error_auth_failed':
return (
<Card.Text>
Authentication with the SSO provider failed. Please try again later.
Headplane logs may provide more information.
</Card.Text>
);
default:
return (
<Card.Text>
An unknown error occurred during OIDC authentication. Please try again
later.
</Card.Text>
);
}
}
+156
View File
@@ -0,0 +1,156 @@
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 {}
const qp = new URL(request.url).searchParams;
const urlState = qp.get("s") ?? undefined;
const oidcConnector = await context.oidcConnector?.get();
// MARK: This works because the OIDC connector will always return false
// for `isExclusive` if the OIDC config isn't usable.
if (oidcConnector?.isExclusive && urlState !== "logout") {
return redirect("/oidc/start");
}
const isOidcConnectorEnabled = oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled ? (oidcConnector?.errors ?? []) : [];
return {
isCookieSecureEnabled: context.config.server.cookie_secure,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
};
}
export const action = loginAction;
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
const { isCookieSecureEnabled, isOidcConnectorEnabled, oidcErrorCodes, urlState } = loaderData;
const [showCookieWarning, setShowCookieWarning] = useState(false);
const [params] = useSearchParams();
const { pause } = useLiveData();
useEffect(() => {
// This page does NOT need stale while revalidate logic
pause();
if (isCookieSecureEnabled && window.location.protocol !== "https:") {
setShowCookieWarning(true);
}
});
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (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;
window.history.replaceState(null, "", newUrl);
}
}, [urlState, params]);
if (urlState === "logout") {
return <Logout />;
}
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>
);
}
+12 -5
View File
@@ -9,14 +9,21 @@ export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
return redirect('/login');
try {
await context.sessions.auth(request);
} catch {
redirect('/login');
}
return redirect('/login', {
// When API key is disabled, we need to explicitly redirect
// with a logout state to prevent auto login again.
const url = context.config.oidc?.disable_api_key_login
? '/login?s=logout'
: '/login';
return redirect(url, {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
'Set-Cookie': await context.sessions.destroySession(),
},
});
}
+146 -60
View File
@@ -1,69 +1,155 @@
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
import { 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.oidc) {
throw new Error('OIDC is not enabled');
}
import { users } from "~/server/db/schema";
import { Roles } from "~/server/web/roles";
import log from "~/utils/log";
import { createOidcStateCookie } from "~/utils/oidc-state";
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/login');
}
import type { Route } from "./+types/oidc-callback";
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if (session.get('state') !== 'flow') {
return redirect('/login'); // Haven't started an OIDC flow
}
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 payload = session.get('oidc')!;
const { code_verifier, state, nonce, redirect_uri } = payload;
if (!code_verifier || !state || !nonce || !redirect_uri) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
}
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect("/login?s=error_no_query");
}
// Reconstruct the redirect URI using the query parameters
// and the one we saved in the session
const flowRedirectUri = new URL(redirect_uri);
flowRedirectUri.search = url.search;
const cookie = createOidcStateCookie(context.config);
const oidcCookieState = await cookie.parse(request.headers.get("Cookie"));
const flowOptions = {
redirect_uri: flowRedirectUri.toString(),
code_verifier,
state,
nonce: nonce === '<none>' ? undefined : nonce,
};
if (oidcCookieState == null) {
log.warn("auth", "Called OIDC callback without session cookie");
return redirect("/login?s=error_no_session");
}
try {
const user = await finishAuthFlow(context.oidc, flowOptions);
session.unset('oidc');
const userSession = session as Session<AuthSession>;
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");
}
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
userSession.set('user', user);
userSession.set('api_key', context.config.oidc?.headscale_api_key!);
userSession.set('state', 'auth');
return redirect('/machines', {
headers: {
'Set-Cookie': await context.sessions.commit(userSession),
},
});
} catch (error) {
return new Response(JSON.stringify(formatError(error)), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
try {
const callbackUrl = new URL(redirect_uri);
const currentUrl = new URL(request.url);
callbackUrl.search = currentUrl.search;
const tokens = await oidc.authorizationCodeGrant(oidcConnector.client, callbackUrl, {
expectedState: state,
expectedNonce: nonce,
...(oidcConnector.usePKCE ? { pkceCodeVerifier: verifier } : {}),
});
const claims = tokens.claims();
if (claims?.sub == null) {
log.warn("auth", "No subject found in OIDC claims");
return redirect("/login?s=error_no_sub");
}
const userInfo = await oidc.fetchUserInfo(
oidcConnector.client,
tokens.access_token,
claims.sub,
);
// We have defaults that closely follow what Headscale uses, maybe we
// can make it configurable in the future, but for now we only need the
// `sub` claim.
const username = userInfo.preferred_username ?? userInfo.email?.split("@")[0] ?? "user";
const name =
userInfo.name ??
(userInfo.given_name && userInfo.family_name
? `${userInfo.given_name} ${userInfo.family_name}`
: (userInfo.preferred_username ?? "SSO User"));
const picture =
context.config.oidc?.profile_picture_source === "gravatar"
? (() => {
if (!userInfo.email) {
return undefined;
}
const emailHash = userInfo.email.trim().toLowerCase();
const hash = createHash("sha256").update(emailHash).digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
})()
: userInfo.picture;
const [{ count: userCount }] = await context.db
.select({ count: count() })
.from(users)
.where(eq(users.caps, Roles.owner));
await context.db
.insert(users)
.values({
id: ulid(),
sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
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));
// 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");
}
}
+75 -36
View File
@@ -1,42 +1,81 @@
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
import * as oidc from "openid-client";
import { data, redirect } from "react-router";
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if ((session as Session<AuthSession>).has('api_key')) {
return redirect('/machines');
}
import { HeadplaneConfig } from "~/server/config/config-schema";
import { createOidcStateCookie } from "~/utils/oidc-state";
if (!context.oidc) {
throw new Error('OIDC is not enabled');
}
import type { Route } from "./+types/oidc-start";
const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(
context.oidc,
redirectUri,
// We can't get here without the OIDC config being defined
context.config.oidc!.token_endpoint_auth_method,
);
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
return redirect("/");
} catch {}
session.set('state', 'flow');
session.set('oidc', {
state: data.state,
nonce: data.nonce,
code_verifier: data.codeVerifier,
redirect_uri: redirectUri,
});
const oidcConnector = await context.oidcConnector?.get();
if (!oidcConnector?.isValid) {
throw data("OIDC is not enabled or misconfigured", { status: 501 });
}
return redirect(data.url, {
status: 302,
headers: {
'Set-Cookie': await context.sessions.commit(session),
},
});
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,
}),
},
});
}
function getRedirectUri(config: HeadplaneConfig, req: Request): string {
if (config.server.base_url != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.server.base_url);
return url.href;
}
if (config.oidc?.redirect_uri != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.oidc.redirect_uri);
return url.href;
}
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
let host = req.headers.get("Host");
if (!host) {
host = req.headers.get("X-Forwarded-Host");
}
if (!host) {
throw data("Cannot determine redirect URI: no Host or X-Forwarded-Host header", {
status: 500,
});
}
const proto = req.headers.get("X-Forwarded-Proto");
url.protocol = proto ?? "http:";
url.host = host;
return url.href;
}
+25 -25
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 bg-white dark:bg-headplane-900' : '',
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
)}
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
@@ -179,22 +179,22 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
<GripVertical
{...attributes}
{...listeners}
className="p-0.5 focus:ring outline-none rounded-md"
className="p-0.5 focus:ring-3 outline-hidden rounded-md"
/>
)}
{domain}
</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>
+69 -15
View File
@@ -1,25 +1,33 @@
import { Form } from 'react-router';
import { Info } from 'lucide-react';
import { Form, useSubmit } from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import Switch from '~/components/Switch';
import TableList from '~/components/TableList';
import Tooltip from '~/components/Tooltip';
import cn from '~/utils/cn';
import AddNS from '../dialogs/add-ns';
interface Props {
nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
isDisabled: boolean;
}
export default function ManageNS({ nameservers, isDisabled }: Props) {
export default function ManageNS({
nameservers,
isDisabled,
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>
@@ -27,11 +35,12 @@ export default function ManageNS({ nameservers, isDisabled }: Props) {
<div className="mt-4">
{Object.keys(nameservers).map((key) => (
<NameserverList
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers}
isGlobal={key === 'global'}
key={key}
name={key}
nameservers={nameservers}
overrideLocalDns={overrideLocalDns}
/>
))}
@@ -45,6 +54,7 @@ interface ListProps {
isGlobal: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
name: string;
}
@@ -52,9 +62,12 @@ function NameserverList({
isGlobal,
isDisabled,
nameservers,
overrideLocalDns,
name,
}: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name];
const submit = useSubmit();
if (list.length === 0) {
return null;
}
@@ -62,9 +75,50 @@ function NameserverList({
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium opacity-80">
{isGlobal ? 'Global Nameservers' : name}
</h2>
{isGlobal ? (
<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>
<div className="flex items-center gap-2 text-sm">
<Tooltip>
<Info className="size-4" />
<Tooltip.Body>
When enabled, use the DNS servers listed below to resolve
names outside the tailnet. When disabled (default), devices
will prefer their local DNS configuration.
<Link
name="Tailscale Global Nameservers Documentation"
to="https://tailscale.com/kb/1054/dns#global-nameservers"
>
Learn More
</Link>
</Tooltip.Body>
</Tooltip>
<p>Override DNS servers</p>
<Switch
className="h-[15px] w-[23px] p-0.5"
defaultSelected={overrideLocalDns}
label="Override local DNS settings"
name="override_dns"
onChange={(v) => {
submit(
{
action_id: 'override_dns',
override_dns: v ? 'true' : 'false',
},
{
method: 'POST',
},
);
}}
switchClassName="h-[9px] w-[9px]"
/>
</div>
</div>
) : (
<h2 className="text-md font-medium opacity-80">{name}</h2>
)}
</div>
<TableList>
{list.length > 0
@@ -72,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>
+22 -20
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> records are supported.{' '}
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,33 +32,35 @@ 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-24 items-center">
<div className="flex gap-4 items-center">
<p
className={cn(
'font-mono text-sm font-bold py-1 px-2 rounded-md',
'bg-headplane-100 dark:bg-headplane-700/30',
)}
>
{record.type}
<div className="flex gap-2 items-center w-full">
<p
className={cn(
'font-mono text-sm font-bold py-1 px-2 rounded-md text-center',
'bg-headplane-100 dark:bg-headplane-700/30 min-w-12',
)}
>
{record.type}
</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>
<p className="font-mono text-sm">{record.name}</p>
</div>
<p className="font-mono text-sm">{record.value}</p>
</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>
+9 -9
View File
@@ -1,4 +1,4 @@
import { RepoForkedIcon } from '@primer/octicons-react';
import { Split } from 'lucide-react';
import { useMemo, useState } from 'react';
import Chip from '~/components/Chip';
import Dialog from '~/components/Dialog';
@@ -35,15 +35,15 @@ export default function AddNameserver({ nameservers }: Props) {
<Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel>
<Dialog.Title className="mb-4">Add nameserver</Dialog.Title>
<input type="hidden" name="action_id" value="add_ns" />
<input name="action_id" type="hidden" value="add_ns" />
<Input
description="Use this IPv4 or IPv6 address to resolve names."
isInvalid={isInvalid}
isRequired
label="Nameserver"
description="Use this IPv4 or IPv6 address to resolve names."
placeholder="1.2.3.4"
name="ns"
onChange={setNs}
isInvalid={isInvalid}
placeholder="1.2.3.4"
/>
<div className="flex items-center justify-between mt-8">
<div className="block">
@@ -53,9 +53,9 @@ export default function AddNameserver({ nameservers }: Props) {
</Dialog.Text>
<Tooltip>
<Chip
text="Split DNS"
leftIcon={<RepoForkedIcon className="w-4 h-4 mr-0.5" />}
className={cn('inline-flex items-center')}
leftIcon={<Split className="w-3 h-3 mr-0.5" />}
text="Split DNS"
/>
<Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or later
@@ -76,9 +76,9 @@ export default function AddNameserver({ nameservers }: Props) {
<Input
isRequired={split === true}
label="Domain"
placeholder="example.com"
name="split_name"
onChange={setDomain}
placeholder="example.com"
/>
<Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this suffix
@@ -86,7 +86,7 @@ export default function AddNameserver({ nameservers }: Props) {
</Dialog.Text>
</>
) : (
<input type="hidden" name="split_name" value="global" />
<input name="split_name" type="hidden" value="global" />
)}
</Dialog.Panel>
</Dialog>
+24 -4
View File
@@ -2,12 +2,14 @@ import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Select from '~/components/Select';
interface Props {
records: { name: string; type: 'A' | string; value: string }[];
records: { name: string; type: 'A' | 'AAAA' | string; value: string }[];
}
export default function AddRecord({ records }: Props) {
const [type, setType] = useState<'A' | 'AAAA' | string>('A');
const [name, setName] = useState('');
const [ip, setIp] = useState('');
@@ -22,14 +24,30 @@ export default function AddRecord({ records }: Props) {
return (
<Dialog>
<Dialog.Button>Add DNS record</Dialog.Button>
<Dialog.Panel>
<Dialog.Panel
onSubmit={() => {
setName('');
setIp('');
}}
>
<Dialog.Title>Add DNS record</Dialog.Title>
<Dialog.Text>
Enter the domain and IP address for the new DNS record.
</Dialog.Text>
<div className="flex flex-col gap-2 mt-4">
<input type="hidden" name="action_id" value="add_record" />
<input type="hidden" name="record_type" value="A" />
<Select
isRequired
label="Record Type"
name="record_type"
defaultInputValue={type}
onSelectionChange={(v) => {
if (v) setType(v.toString() as 'A' | 'AAAA');
}}
>
<Select.Item key="A">A</Select.Item>
<Select.Item key="AAAA">AAAA</Select.Item>
</Select>
<Input
isRequired
label="Domain"
@@ -41,7 +59,9 @@ export default function AddRecord({ records }: Props) {
<Input
isRequired
label="IP Address"
placeholder="101.101.101.101"
placeholder={
type === 'AAAA' ? '2001:db8::ff00:42:8329' : '101.101.101.101'
}
name="record_value"
onChange={setIp}
isInvalid={isDuplicate}
+215 -207
View File
@@ -1,15 +1,24 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { hp_getIntegration } from '~/utils/integration/loader';
import { data } from 'react-router';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
export async function dnsAction({ request, context }: Route.ActionArgs) {
const check = await context.sessions.check(
request,
Capabilities.write_network,
);
if (!check) {
return data({ success: false }, 403);
}
export async function dnsAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
if (!context.hs.writable()) {
return data({ success: false }, 403);
}
// We only need it for health checks which don't require auth
const api = context.hsApi.getRuntimeClient('fake-api-key');
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
@@ -17,207 +26,206 @@ export async function dnsAction({
}
switch (action) {
case 'rename_tailnet':
return renameTailnet(formData, context);
case 'toggle_magic':
return toggleMagic(formData, context);
case 'remove_ns':
return removeNs(formData, context);
case 'add_ns':
return addNs(formData, context);
case 'remove_domain':
return removeDomain(formData, context);
case 'add_domain':
return addDomain(formData, context);
case 'remove_record':
return removeRecord(formData, context);
case 'add_record':
return addRecord(formData, context);
case 'rename_tailnet': {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.base_domain',
value: newName,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Tailnet renamed successfully' };
}
case 'toggle_magic': {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Magic DNS state updated successfully' };
}
case 'remove_ns': {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers.length > 0 ? servers : null,
},
]);
}
await context.integration?.onConfigChange(api);
return { message: 'Nameserver removed successfully' };
}
case 'add_ns': {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await context.integration?.onConfigChange(api);
return { message: 'Nameserver added successfully' };
}
case 'remove_domain': {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Domain removed successfully' };
}
case 'add_domain': {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains;
domains.push(domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Domain added successfully' };
}
case 'remove_record': {
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
// Value is not needed for removal
const restart = await context.hs.removeDNS({
name: recordName,
type: recordType,
value: '',
});
if (!restart) {
return;
}
await context.integration?.onConfigChange(api);
return { message: 'DNS record removed successfully' };
}
case 'add_record': {
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
const restart = await context.hs.addDNS({
name: recordName,
type: recordType,
value: recordValue,
});
if (!restart) {
return;
}
await context.integration?.onConfigChange(api);
return { message: 'DNS record added successfully' };
}
case 'override_dns': {
const override = formData.get('override_dns')?.toString();
if (!override) {
return data({ success: false }, 400);
}
const overrideValue = override === 'true';
await context.hs.patch([
{
path: 'dns.override_local_dns',
value: overrideValue,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'DNS override updated successfully' };
}
default:
return data({ success: false }, 400);
}
}
async function renameTailnet(formData: FormData, context: LoadContext) {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.base_domain',
value: newName,
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function toggleMagic(formData: FormData, context: LoadContext) {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function removeNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await hp_getIntegration()?.onConfigChange();
}
async function addNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await hp_getIntegration()?.onConfigChange();
}
async function removeDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function addDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains;
domains.push(domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function removeRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
const records = config.dns.extra_records.filter(
(i) => i.name !== recordName || i.type !== recordType,
);
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
},
]);
await hp_getIntegration()?.onConfigChange();
}
async function addRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
const records = config.dns.extra_records;
records.push({ name: recordName, type: recordType, value: recordValue });
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
},
]);
await hp_getIntegration()?.onConfigChange();
}
+42 -10
View File
@@ -3,6 +3,7 @@ import { useLoaderData } from 'react-router';
import Code from '~/components/Code';
import Notice from '~/components/Notice';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import ManageDomains from './components/manage-domains';
import ManageNS from './components/manage-ns';
import ManageRecords from './components/manage-records';
@@ -11,11 +12,30 @@ import ToggleMagic from './components/toggle-magic';
import { dnsAction } from './dns-actions';
// We do not want to expose every config value
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.hs.readable()) {
throw new Error('No configuration is available');
}
const check = await context.sessions.check(
request,
Capabilities.read_network,
);
if (!check) {
// Not authorized to view this page
throw new Error(
'You do not have permission to view this page. Please contact your administrator.',
);
}
const writablePermission = await context.sessions.check(
request,
Capabilities.write_network,
);
const config = context.hs.c!;
const dns = {
prefixes: config.prefixes,
@@ -24,11 +44,13 @@ export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains,
extraRecords: config.dns.extra_records,
overrideDns: config.dns.override_local_dns,
extraRecords: context.hs.d,
};
return {
...dns,
access: writablePermission,
writable: context.hs.writable(),
};
}
@@ -46,26 +68,36 @@ export default function Page() {
}
allNs.global = data.nameservers;
const isDisabled = data.writable === false;
const isDisabled = data.access === false || data.writable === false;
return (
<div className="flex flex-col gap-16 max-w-screen-lg">
<div className="flex flex-col gap-16 max-w-(--breakpoint-lg)">
{data.writable ? undefined : (
<Notice>
The Headscale configuration is read-only. You cannot make changes to
the configuration
</Notice>
)}
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
<ManageNS nameservers={allNs} isDisabled={isDisabled} />
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
{data.access ? undefined : (
<Notice>
Your permissions do not allow you to modify the DNS settings for this
tailnet.
</Notice>
)}
<RenameTailnet isDisabled={isDisabled} name={data.baseDomain} />
<ManageNS
isDisabled={isDisabled}
nameservers={allNs}
overrideLocalDns={data.overrideDns}
/>
<ManageRecords isDisabled={isDisabled} records={data.extraRecords} />
<ManageDomains
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.
@@ -76,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>
);
-225
View File
@@ -1,225 +0,0 @@
import type { ActionFunctionArgs } from 'react-router';
import type { LoadContext } from '~/server';
import log from '~/utils/log';
import { send } from '~/utils/res';
// TODO: Turn this into the same thing as dns-actions like machine-actions!!!
export async function menuAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const data = await request.formData();
if (!data.has('_method') || !data.has('id')) {
return send(
{ message: 'No method or ID provided' },
{
status: 400,
},
);
}
const id = String(data.get('id'));
const method = String(data.get('_method'));
switch (method) {
case 'delete': {
await context.client.delete(`v1/node/${id}`, session.get('api_key')!);
return { message: 'Machine removed' };
}
case 'expire': {
await context.client.post(
`v1/node/${id}/expire`,
session.get('api_key')!,
);
return { message: 'Machine expired' };
}
case 'rename': {
if (!data.has('name')) {
return send(
{ message: 'No name provided' },
{
status: 400,
},
);
}
const name = String(data.get('name'));
await context.client.post(
`v1/node/${id}/rename/${name}`,
session.get('api_key')!,
);
return { message: 'Machine renamed' };
}
case 'routes': {
if (!data.has('route') || !data.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const route = String(data.get('route'));
const enabled = data.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await context.client.post(
`v1/routes/${route}/${postfix}`,
session.get('api_key')!,
);
return { message: 'Route updated' };
}
case 'exit-node': {
if (!data.has('routes') || !data.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const routes = data.get('routes')?.toString().split(',') ?? [];
const enabled = data.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await Promise.all(
routes.map(async (route) => {
await context.client.post(
`v1/routes/${route}/${postfix}`,
session.get('api_key')!,
);
}),
);
return { message: 'Exit node updated' };
}
case 'move': {
if (!data.has('to')) {
return send(
{ message: 'No destination provided' },
{
status: 400,
},
);
}
const to = String(data.get('to'));
try {
await context.client.post(
`v1/node/${id}/user`,
session.get('api_key')!,
{
user: to,
},
);
return { message: `Moved node ${id} to ${to}` };
} catch (error) {
console.error(error);
return send(
{ message: `Failed to move node ${id} to ${to}` },
{
status: 500,
},
);
}
}
case 'tags': {
const tags =
data
.get('tags')
?.toString()
.split(',')
.filter((tag) => tag.trim() !== '') ?? [];
try {
await context.client.post(
`v1/node/${id}/tags`,
session.get('api_key')!,
{
tags,
},
);
return { message: 'Tags updated' };
} catch (error) {
log.debug('api', 'Failed to update tags: %s', error);
return send(
{ message: 'Failed to update tags' },
{
status: 500,
},
);
}
}
case 'register': {
const key = data.get('mkey')?.toString();
const user = data.get('user')?.toString();
if (!key) {
return send(
{ message: 'No machine key provided' },
{
status: 400,
},
);
}
if (!user) {
return send(
{ message: 'No user provided' },
{
status: 400,
},
);
}
try {
const qp = new URLSearchParams();
qp.append('user', user);
qp.append('key', key);
const url = `v1/node/register?${qp.toString()}`;
await context.client.post(url, session.get('api_key')!, {
user,
key,
});
return {
success: true,
message: 'Machine registered',
};
} catch {
return send(
{
success: false,
message: 'Failed to register machine',
},
{
status: 500,
},
);
}
}
default: {
return send(
{ message: 'Invalid method' },
{
status: 400,
},
);
}
}
}
@@ -0,0 +1,214 @@
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[];
supportsNodeOwnerChange: boolean;
}
export default function MachineRow({
node,
users,
isAgent,
magic,
isDisabled,
existingTags,
supportsNodeOwnerChange,
}: Props) {
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
const ipOptions = useMemo(() => {
if (magic) {
return [...node.ipAddresses, `${node.givenName}.${magic}`];
}
return node.ipAddresses;
}, [magic, node.ipAddresses]);
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");
}
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.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 (isAgent === true) {
uiTags.push("headplane-agent");
}
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} />;
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 "tailscale-ssh":
return <TailscaleSSHTag key={tag} />;
case "headplane-agent":
return <HeadplaneAgentTag key={tag} />;
default:
return null;
}
});
}
-198
View File
@@ -1,198 +0,0 @@
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-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 type { HostInfo, Machine, Route, User } from '~/types';
import cn from '~/utils/cn';
import * as hinfo from '~/utils/host-info';
import toast from '~/utils/toast';
import MenuOptions from './menu';
interface Props {
machine: Machine;
routes: Route[];
users: User[];
isAgent?: boolean;
magic?: string;
stats?: HostInfo;
}
export default function MachineRow({
machine,
routes,
users,
isAgent,
magic,
stats,
}: Props) {
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
machine.expiry === '0001-01-01T00:00:00Z' ||
machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now();
const tags = [...new Set([...machine.forcedTags, ...machine.validTags])];
if (expired) {
tags.unshift('Expired');
}
const prefix = magic?.startsWith('[user]')
? magic.replace('[user]', machine.user.name)
: magic;
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce<{
exit: Route[];
subnetApproved: Route[];
subnet: Route[];
}>(
(acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route);
return acc;
}
if (route.enabled) {
acc.subnetApproved.push(route);
return acc;
}
acc.subnet.push(route);
return acc;
},
{ exit: [], subnetApproved: [], subnet: [] },
);
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled;
}, [exit]);
if (exitEnabled) {
tags.unshift('Exit Node');
}
if (subnetApproved.length > 0) {
tags.unshift('Subnets');
}
if (isAgent) {
tags.unshift('Headplane Agent');
}
const ipOptions = useMemo(() => {
if (magic) {
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
}
return machine.ipAddresses;
}, [magic, machine.ipAddresses]);
return (
<tr
key={machine.id}
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
>
<td className="pl-0.5 py-2 focus-within:ring">
<Link
to={`/machines/${machine.id}`}
className={cn('group/link h-full focus:outline-none')}
>
<p
className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400',
)}
>
{machine.givenName}
</p>
<p className="text-sm font-mono opacity-50">{machine.name}</p>
<div className="flex gap-1 mt-1">
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</Link>
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
<Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses">
<ChevronDownIcon 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}
<CopyIcon 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">
{stats !== undefined ? (
<>
<p className="leading-snug">{hinfo.getTSVersion(stats)}</p>
<p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(stats)}
</p>
</>
) : (
<p className="text-sm opacity-50">Unknown</p>
)}
</td>
) : undefined}
<td className="py-2">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-headplane-600 dark:text-headplane-300',
)}
>
<StatusCircle
isOnline={machine.online && !expired}
className="w-4 h-4"
/>
<p>
{machine.online && !expired
? 'Connected'
: new Date(machine.lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
/>
</td>
</tr>
);
}
+79 -36
View File
@@ -1,8 +1,10 @@
import { Cog, Ellipsis } from 'lucide-react';
import { Cog, Ellipsis, SquareTerminal } from 'lucide-react';
import { useState } from 'react';
import Button from '~/components/Button';
import Menu from '~/components/Menu';
import type { Machine, Route, User } from '~/types';
import type { User } from '~/types';
import cn from '~/utils/cn';
import { PopulatedNode } from '~/utils/node-info';
import Delete from '../dialogs/delete';
import Expire from '../dialogs/expire';
import Move from '../dialogs/move';
@@ -11,37 +13,36 @@ import Routes from '../dialogs/routes';
import Tags from '../dialogs/tags';
interface MenuProps {
machine: Machine;
routes: Route[];
node: PopulatedNode;
users: User[];
magic?: string;
isFullButton?: boolean;
isDisabled?: boolean;
existingTags?: string[];
supportsNodeOwnerChange: boolean;
}
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
export default function MachineMenu({
machine,
routes,
node,
magic,
users,
isFullButton,
isDisabled,
existingTags,
supportsNodeOwnerChange,
}: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
const expired =
machine.expiry === '0001-01-01 00:00:00' ||
machine.expiry === '0001-01-01T00:00:00Z' ||
machine.expiry === null
? false
: new Date(machine.expiry).getTime() < Date.now();
const supportsTailscaleSSH =
node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0;
return (
<>
<div className="flex items-center justify-end px-4 gap-1.5">
{modal === 'remove' && (
<Delete
machine={machine}
isOpen={modal === 'remove'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
@@ -49,19 +50,19 @@ export default function MachineMenu({
)}
{modal === 'move' && (
<Move
machine={machine}
users={users}
isOpen={modal === 'move'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
users={users}
/>
)}
{modal === 'rename' && (
<Rename
machine={machine}
magic={magic}
isOpen={modal === 'rename'}
machine={node}
magic={magic}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
@@ -69,9 +70,8 @@ export default function MachineMenu({
)}
{modal === 'routes' && (
<Routes
machine={machine}
routes={routes}
isOpen={modal === 'routes'}
node={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
@@ -79,24 +79,68 @@ export default function MachineMenu({
)}
{modal === 'tags' && (
<Tags
machine={machine}
existingTags={existingTags}
isOpen={modal === 'tags'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{expired && modal === 'expire' ? undefined : (
{node.expired && modal === 'expire' ? undefined : (
<Expire
machine={machine}
isOpen={modal === 'expire'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
<Menu>
{supportsTailscaleSSH ? (
isFullButton ? (
<Button
className="flex items-center gap-x-2"
onPress={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
'_blank',
'noopener,noreferrer,width=800,height=600',
);
}}
variant="heavy"
>
<SquareTerminal className="h-5" />
<p>SSH</p>
</Button>
) : (
<Button
className={cn(
'py-0.5 w-fit bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
'opacity-0 pointer-events-none group-hover:opacity-100',
'group-hover:pointer-events-auto',
)}
onPress={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
'_blank',
'noopener,noreferrer,width=800,height=600',
);
}}
>
SSH
</Button>
)
) : undefined}
<Menu isDisabled={isDisabled}>
{isFullButton ? (
<Menu.Button className="flex items-center gap-x-2">
<Cog className="h-5" />
@@ -104,37 +148,36 @@ export default function MachineMenu({
</Menu.Button>
) : (
<Menu.IconButton
label="Machine Options"
className={cn(
'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
)}
label="Machine Options"
>
<Ellipsis className="h-5" />
</Menu.IconButton>
)}
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
<Menu.Panel
disabledKeys={node.expired ? ['expire'] : []}
onAction={(key) => setModal(key as Modal)}
>
<Menu.Section>
<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>
{expired ? (
<></>
) : (
<Menu.Item key="expire" textValue="Expire">
<p className="text-red-500 dark:text-red-400">Expire</p>
</Menu.Item>
)}
<Menu.Item key="expire" textValue="Expire">
<p className="text-red-500 dark:text-red-400">Expire</p>
</Menu.Item>
<Menu.Item key="remove" textValue="Remove">
<p className="text-red-500 dark:text-red-400">Remove</p>
</Menu.Item>
</Menu.Section>
</Menu.Panel>
</Menu>
</>
</div>
);
}
+3 -3
View File
@@ -14,16 +14,16 @@ export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel
variant="destructive"
onSubmit={() => navigate('/machines')}
variant="destructive"
>
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from your network. To re-add
it, you will need to reauthenticate to your tailnet from the device.
</Dialog.Text>
<input type="hidden" name="_method" value="delete" />
<input type="hidden" name="id" value={machine.id} />
<input name="action_id" type="hidden" value="delete" />
<input name="node_id" type="hidden" value={machine.id} />
</Dialog.Panel>
</Dialog>
);
+2 -2
View File
@@ -16,8 +16,8 @@ export default function Expire({ machine, isOpen, setIsOpen }: ExpireProps) {
This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device.
</Dialog.Text>
<input type="hidden" name="_method" value="expire" />
<input type="hidden" name="id" value={machine.id} />
<input name="action_id" type="hidden" value="expire" />
<input name="node_id" type="hidden" value={machine.id} />
</Dialog.Panel>
</Dialog>
);
+17 -7
View File
@@ -1,3 +1,4 @@
import { Key, useState } from 'react';
import Dialog from '~/components/Dialog';
import Select from '~/components/Select';
import type { Machine, User } from '~/types';
@@ -10,23 +11,32 @@ interface MoveProps {
}
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
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.
</Dialog.Text>
<input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} />
<input name="action_id" type="hidden" value="reassign" />
<input name="node_id" type="hidden" value={machine.id} />
<input name="user_id" type="hidden" value={userId?.toString()} />
<Select
label="Owner"
name="to"
placeholder="Select a user"
defaultSelectedKey={machine.user.id}
isRequired
label="Owner"
name="user"
onSelectionChange={(key) => {
setUserId(key);
}}
placeholder="Select a user"
>
{users.map((user) => (
<Select.Item key={user.id}>{user.name}</Select.Item>
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
</Dialog.Panel>
+20 -16
View File
@@ -1,4 +1,4 @@
import { Computer, KeySquare } from 'lucide-react';
import { Computer, FileKey2 } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import Code from '~/components/Code';
@@ -8,38 +8,40 @@ import Menu from '~/components/Menu';
import Select from '~/components/Select';
import type { User } from '~/types';
export interface NewProps {
export interface NewMachineProps {
server: string;
users: User[];
isDisabled?: boolean;
disabledKeys?: string[];
}
export default function New(data: NewProps) {
export default function NewMachine(data: NewMachineProps) {
const [pushDialog, setPushDialog] = useState(false);
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{' '}
<Code isCopyable>
tailscale up --login-server=
{data.server}
</Code>{' '}
on your device.
<Code isCopyable>tailscale up --login-server={data.server}</Code> on
your device.
</Dialog.Text>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<input name="action_id" type="hidden" value="register" />
<Input
errorMessage="Machine key must be exactly 24 characters"
isInvalid={isMkeyInvalid}
isRequired
label="Machine Key"
name="register_key"
onChange={setMkey}
placeholder="AbCd..."
validationBehavior="native"
name="mkey"
onChange={setMkey}
/>
<Select
isRequired
@@ -48,12 +50,14 @@ export default function New(data: NewProps) {
placeholder="Select a user"
>
{data.users.map((user) => (
<Select.Item key={user.id}>{user.name}</Select.Item>
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
<Menu>
<Menu disabledKeys={data.disabledKeys} isDisabled={data.isDisabled}>
<Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel
onAction={(key) => {
@@ -76,7 +80,7 @@ export default function New(data: NewProps) {
</Menu.Item>
<Menu.Item key="pre-auth" textValue="Generate Pre-auth Key">
<div className="flex items-center gap-x-3">
<KeySquare className="w-4" />
<FileKey2 className="w-4" />
Generate Pre-auth Key
</div>
</Menu.Item>
+36 -5
View File
@@ -27,14 +27,45 @@ export default function Rename({
This name is shown in the admin panel, in Tailscale clients, and used
when generating MagicDNS names.
</Dialog.Text>
<input type="hidden" name="_method" value="rename" />
<input type="hidden" name="id" value={machine.id} />
<input name="action_id" type="hidden" value="rename" />
<input name="node_id" type="hidden" value={machine.id} />
<Input
label="Machine name"
placeholder="Machine name"
name="name"
defaultValue={machine.givenName}
isRequired
label="Machine name"
name="name"
onChange={setName}
placeholder="Machine name"
validate={(value) => {
if (value.length === 0) {
return 'Cannot be empty';
}
// DNS hostname validation
if (value.toLowerCase() !== value) {
return 'Cannot contain uppercase letters';
}
if (value.length > 63) {
return 'DNS hostnames cannot be 64+ characters';
}
// Test for invalid characters
if (!/^[a-z0-9-]+$/.test(value)) {
return 'Cannot contain special characters';
}
// Test for leading/trailing hyphens
if (value.startsWith('-') || value.endsWith('-')) {
return 'Cannot start or end with a hyphen';
}
// Test for consecutive hyphens
if (value.includes('--')) {
return 'Cannot contain consecutive hyphens';
}
}}
validationBehavior="native"
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
+28 -48
View File
@@ -1,68 +1,43 @@
import { GlobeLock, RouteOff } from 'lucide-react';
import { useMemo } from 'react';
import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import Switch from '~/components/Switch';
import TableList from '~/components/TableList';
import type { Machine, Route } from '~/types';
import cn from '~/utils/cn';
import { PopulatedNode } from '~/utils/node-info';
interface RoutesProps {
machine: Machine;
routes: Route[];
node: PopulatedNode;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
}
// TODO: Support deleting routes
export default function Routes({
machine,
routes,
isOpen,
setIsOpen,
}: RoutesProps) {
export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
const fetcher = useFetcher();
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet } = routes.reduce<{
exit: Route[];
subnet: Route[];
}>(
(acc, route) => {
if (route.prefix === '::/0' || route.prefix === '0.0.0.0/0') {
acc.exit.push(route);
return acc;
}
acc.subnet.push(route);
return acc;
},
{ exit: [], subnet: [] },
);
const exitEnabled = useMemo(() => {
if (exit.length !== 2) return false;
return exit[0].enabled && exit[1].enabled;
}, [exit]);
const subnets = [
...node.customRouting.subnetApprovedRoutes,
...node.customRouting.subnetWaitingRoutes,
];
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel variant="unactionable">
<Dialog.Title>Edit route settings of {machine.givenName}</Dialog.Title>
<Dialog.Title>Edit route settings of {node.givenName}</Dialog.Title>
<Dialog.Text className="font-bold">Subnet routes</Dialog.Text>
<Dialog.Text>
Connect to devices you can&apos;t install Tailscale on by advertising
IP ranges as subnet routes.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
to="https://tailscale.com/kb/1019/subnets"
>
Learn More
</Link>
</Dialog.Text>
<TableList className="mt-4">
{subnet.length === 0 ? (
{subnets.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<RouteOff />
<p className="font-semibold">
@@ -70,17 +45,17 @@ export default function Routes({
</p>
</TableList.Item>
) : undefined}
{subnet.map((route) => (
<TableList.Item key={route.id}>
<p>{route.prefix}</p>
{subnets.map((route) => (
<TableList.Item key={route}>
<p>{route}</p>
<Switch
defaultSelected={route.enabled}
defaultSelected={node.approvedRoutes.includes(route)}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set('id', machine.id);
form.set('_method', 'routes');
form.set('route', route.id);
form.set('action_id', 'update_routes');
form.set('node_id', node.id);
form.set('routes', [route].join(','));
form.set('enabled', String(checked));
fetcher.submit(form, {
@@ -95,14 +70,14 @@ export default function Routes({
<Dialog.Text>
Allow your network to route internet traffic through this machine.{' '}
<Link
to="https://tailscale.com/kb/1103/exit-nodes"
name="Tailscale Exit-node Documentation"
to="https://tailscale.com/kb/1103/exit-nodes"
>
Learn More
</Link>
</Dialog.Text>
<TableList className="mt-4">
{exit.length === 0 ? (
{node.customRouting.exitRoutes.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<GlobeLock />
<p className="font-semibold">This machine is not an exit node</p>
@@ -111,13 +86,18 @@ export default function Routes({
<TableList.Item>
<p>Use as exit node</p>
<Switch
defaultSelected={exitEnabled}
defaultSelected={node.customRouting.exitApproved}
label="Enabled"
onChange={(checked) => {
const form = new FormData();
form.set('id', machine.id);
form.set('_method', 'exit-node');
form.set('routes', exit.map((route) => route.id).join(','));
form.set('action_id', 'update_routes');
form.set('node_id', node.id);
form.set(
'routes',
node.customRouting.exitRoutes
.map((route) => route)
.join(','),
);
form.set('enabled', String(checked));
fetcher.submit(form, {
+135 -95
View File
@@ -1,101 +1,141 @@
import { Plus, TagsIcon, X } from 'lucide-react';
import { useState } from 'react';
import Button from '~/components/Button';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Link from '~/components/Link';
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;
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
existingTags?: string[];
}
export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState('');
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]);
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
to="https://tailscale.com/kb/1068/acl-tags"
name="Tailscale documentation"
>
Tailscale documentation
</Link>{' '}
for more information.
</Dialog.Text>
<input type="hidden" name="_method" value="tags" />
<input type="hidden" name="id" value={machine.id} />
<input type="hidden" name="tags" 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" key={item} id={item}>
{item}
<Button
className="rounded-md p-0.5"
onPress={() => {
setTags(tags.filter((tag) => tag !== item));
}}
>
<X className="p-1" />
</Button>
</TableList.Item>
))
)}
<TableList.Item
className={cn(
'rounded-b-xl focus-within:ring',
tag.length > 0 &&
(!tag.startsWith('tag:') || tags.includes(tag)) &&
'ring ring-red-500 ring-opacity-50',
)}
>
<Input
labelHidden
label="Add a tag"
placeholder="tag:example"
onChange={setTag}
className={cn(
'border-none font-mono p-0',
'rounded-none focus:ring-0 w-full',
)}
/>
<Button
className={cn(
'rounded-md p-0.5',
(!tag.startsWith('tag:') || tags.includes(tag)) &&
'opacity-50 cursor-not-allowed',
)}
isDisabled={
tag.length === 0 ||
!tag.startsWith('tag:') ||
tags.includes(tag)
}
onPress={() => {
setTags([...tags, tag]);
setTag('');
}}
>
<Plus className="p-1" />
</Button>
</TableList.Item>
</TableList>
</Dialog.Panel>
</Dialog>
);
const validNodeTags = useMemo(() => {
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
}, [tags]);
const error = fetcher.data && !fetcher.data.success ? fetcher.data.error : null;
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>
);
}

Some files were not shown because too many files have changed in this diff Show More