Compare commits

...

240 Commits

Author SHA1 Message Date
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
Aarnav Tale 8596a56375 chore: v0.5.7 2025-04-02 14:47:50 -04:00
Aarnav Tale 000ec620b4 fix: serve css and js assets correctly 2025-04-02 14:27:23 -04:00
229 changed files with 16254 additions and 8700 deletions
-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
+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:
+21 -15
View File
@@ -1,8 +1,15 @@
name: Build
on:
push:
paths-ignore:
- ".zed/**"
- "assets/**"
- "docs/**"
- "CHANGELOG.md"
- "README.md"
branches:
- "main"
- "next"
pull_request:
concurrency:
@@ -21,21 +28,15 @@ jobs:
- name: Check out the repo
uses: actions/checkout@v4
- name: Install node.js
uses: actions/setup-node@v4
with:
node-version: 22
- name: Setup Mise
uses: jdx/mise-action@v2
- uses: pnpm/action-setup@v4
name: Install pnpm
with:
version: 10
run_install: false
- name: Get pnpm store directory
- name: Set caching paths
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
echo "GO_CACHE=$(go env GOCACHE)" >> $GITHUB_ENV
echo "GO_MODCACHE=$(go env GOMODCACHE)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
@@ -45,11 +46,16 @@ jobs:
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install
- name: Setup Go cache
uses: actions/cache@v4
with:
path: |
${{ env.GO_CACHE }}
${{ env.GO_MODCACHE }}
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod', '**/go.sum') }}
- name: Build
run: pnpm build
- name: CI pipeline
run: mise run ci
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
+7
View File
@@ -2,4 +2,11 @@ node_modules
/.react-router
/.cache
/build
/test
.env
app/hp_ssh.wasm
app/wasm_exec.js
/docs/.vitepress/dist/
/docs/.vitepress/cache/
/.direnv
-1
View File
@@ -1,2 +1 @@
side-effects-cache = false
public-hoist-pattern[]=*hono*
+9 -1
View File
@@ -6,12 +6,20 @@
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
"source.organizeImports.biome": true,
"source.organizeImports": true
},
"languages": {
"YAML": {
"tab_size": 2,
"hard_tabs": false
},
"Go": {
"formatter": {
"language_server": {
"name": "gopls"
}
}
}
}
}
+73
View File
@@ -1,3 +1,76 @@
### 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)
### IMPORTANT
+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
}
}
+56 -9
View File
@@ -1,16 +1,63 @@
FROM node:22-alpine AS build
WORKDIR /app
FROM --platform=$BUILDPLATFORM jdxcode/mise:latest AS mise-context
COPY mise.toml ./
RUN --mount=type=secret,id=gh_token,env=MISE_GITHUB_TOKEN mise install
RUN npm install -g pnpm@10
RUN apk add --no-cache git
COPY package.json pnpm-lock.yaml ./
FROM --platform=$BUILDPLATFORM mise-context AS go-build
WORKDIR /build/
COPY go.mod go.sum ./
RUN go mod download
COPY cmd/ ./cmd/
COPY internal/ ./internal/
ARG TARGETOS
ARG TARGETARCH
ARG IMAGE_TAG
RUN mkdir -p /build/app/ && \
GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 IMAGE_TAG=$IMAGE_TAG \
mise run wasm ::: agent ::: fake-shell
RUN chmod +x /build/build/hp_agent
RUN chmod +x /build/build/sh
FROM --platform=$BUILDPLATFORM mise-context AS js-build
WORKDIR /build
COPY patches ./patches
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm run build
RUN mise trust
COPY --from=go-build /build/app/hp_ssh.wasm /build/app/hp_ssh.wasm
COPY --from=go-build /build/app/wasm_exec.js /build/app/wasm_exec.js
ARG IMAGE_TAG
RUN IMAGE_TAG=$IMAGE_TAG pnpm run build
RUN mkdir -p /var/lib/headplane/agent
FROM gcr.io/distroless/nodejs22-debian12:latest AS final
COPY --from=js-build /build/build/ /app/build/
COPY --from=js-build /build/drizzle /app/drizzle/
COPY --from=js-build /var/lib/headplane /var/lib/headplane
COPY --from=js-build /build/node_modules/ /app/node_modules/
COPY --from=go-build /build/build/hp_agent /usr/libexec/headplane/agent
# Fake shell to inform the user that they should use the debug image
COPY --from=go-build /build/build/sh /bin/sh
COPY --from=go-build /build/build/sh /bin/bash
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 git
COPY --from=js-build /build/build/ /app/build/
COPY --from=js-build /build/drizzle /app/drizzle/
COPY --from=js-build /var/lib/headplane /var/lib/headplane
COPY --from=js-build /build/node_modules/ /app/node_modules/
COPY --from=go-build /build/build/hp_agent /usr/libexec/headplane/agent
WORKDIR /app
CMD [ "node", "/app/build/server/index.js" ]
+16 -9
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>
@@ -44,38 +44,45 @@ There are 2 ways to deploy Headplane:
Simple mode does not include the automatic management of DNS and Headplane
settings, requiring manual editing and reloading when making changes.
## Versioning
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
Pre-release builds are available under the `next` tag and get updated when a new release
PR is opened and actively in testing.
## Contributing
Headplane is an open-source project and contributions are welcome! If you have
any suggestions, bug reports, or feature requests, please open an issue. Also
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
---
<picture>
<source
media="(prefers-color-scheme: dark)"
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>
-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,
)}
>
+4 -4
View File
@@ -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>
+1 -1
View File
@@ -117,7 +117,7 @@ function Panel(props: DialogPanelProps) {
method={method ?? 'POST'}
ref={ref}
className={cn(
'outline-none rounded-3xl w-full max-w-lg',
'outline-hidden rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
>
+41 -13
View File
@@ -1,5 +1,6 @@
import { AlertIcon } from '@primer/octicons-react';
import { AlertCircle } from 'lucide-react';
import { isRouteErrorResponse, useRouteError } from 'react-router';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
@@ -8,9 +9,29 @@ interface Props {
type?: 'full' | 'embedded';
}
function getMessage(error: Error | unknown) {
export function getErrorMessage(error: Error | unknown): {
title: string;
message: string;
} {
if (error instanceof ResponseError) {
if (error.responseObject?.message) {
return {
title: 'Headscale Error',
message: String(error.responseObject.message),
};
}
return {
title: 'Headscale Error',
message: error.response,
};
}
if (!(error instanceof Error)) {
return 'An unknown error occurred';
return {
title: 'Unknown Error',
message: String(error),
};
}
let rootError = error;
@@ -25,16 +46,19 @@ function getMessage(error: Error | unknown) {
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
return rootError.errors.map((error) => error.message).join('\n');
throw new Error('Unhandled AggregateError');
}
return rootError.message;
return {
title: 'Error',
message: rootError.message,
};
}
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const message = getMessage(error);
const { title, message } = getErrorMessage(error);
return (
<div
@@ -46,14 +70,18 @@ export function ErrorPopup({ type = 'full' }: Props) {
)}
>
<Card>
<div className="flex items-center justify-between">
<Card.Title className="text-3xl mb-0">
{routing ? error.status : 'Error'}
</Card.Title>
<AlertIcon className="w-12 h-12 text-red-500" />
<div className="flex items-center gap-4">
<AlertCircle className="w-8 h-8 text-red-500" />
<div className="flex justify-between items-center gap-2 w-full">
<Card.Title className="text-3xl mb-0">{title}</Card.Title>
{routing && <Code className="text-2xl">{`${error.status}`}</Code>}
</div>
</div>
<Card.Text className="mt-4 text-lg">
{routing ? error.statusText : <Code>{message}</Code>}
<hr className="my-4 text-headplane-100 dark:text-headplane-800" />
<Card.Text
className={cn('py-4 text-lg', routing ? 'font-normal' : 'font-mono')}
>
{routing ? error.data : message}
</Card.Text>
</Card>
</div>
+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'
? [
+3 -3
View File
@@ -54,7 +54,7 @@ export default function Input(props: InputProps) {
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,
@@ -71,14 +71,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(' ')}
</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 });
+7 -5
View File
@@ -60,7 +60,7 @@ function Select(props: SelectProps) {
</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',
)}
@@ -69,7 +69,7 @@ function Select(props: SelectProps) {
{...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
/>
<button
@@ -78,7 +78,9 @@ function Select(props: SelectProps) {
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',
)}
>
<ChevronDown className="p-0.5" />
@@ -125,7 +127,7 @@ function ListBox(props: ListBoxProps) {
<ul
{...listBoxProps}
ref={listBoxRef}
className="w-full max-h-72 overflow-auto outline-none pt-1"
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
@@ -156,7 +158,7 @@ function Option({ item, state }: OptionProps) {
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',
+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>
+1 -1
View File
@@ -56,7 +56,7 @@ function Tab({ item, state }: TabsTabProps) {
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',
)}
+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',
+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>
);
}
+15 -33
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 LoaderFunctionArgs, Outlet, redirect } from 'react-router';
import { ErrorPopup } from '~/components/Error';
import type { LoadContext } from '~/server';
import { ResponseError } from '~/server/headscale/api-client';
import cn from '~/utils/cn';
import { pruneEphemeralNodes } from '~/server/db/pruner';
import ResponseError from '~/server/headscale/api-error';
import log from '~/utils/log';
export async function loader({
request,
context,
...rest
}: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck();
const session = await context.sessions.auth(request);
await pruneEphemeralNodes({ context, request, ...rest });
// We shouldn't session invalidate if Headscale is down
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
if (healthy) {
try {
await context.client.get('v1/apikey', session.get('api_key')!);
await context.client.get('v1/apikey', session.api_key);
} catch (error) {
if (error instanceof ResponseError) {
log.debug('api', 'API Key validation failed %o', error);
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
'Set-Cookie': await context.sessions.destroySession(),
},
});
}
@@ -36,32 +37,13 @@ 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() {
return <ErrorPopup type="embedded" />;
}
+52 -64
View File
@@ -1,4 +1,5 @@
import { BanIcon, CircleCheckIcon } from 'lucide-react';
import { eq } from 'drizzle-orm';
import { CircleCheckIcon } from 'lucide-react';
import {
LoaderFunctionArgs,
Outlet,
@@ -7,13 +8,12 @@ import {
} 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';
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
@@ -23,59 +23,18 @@ export async function loader({
}: LoaderFunctionArgs<LoadContext>) {
try {
const session = await context.sessions.auth(request);
if (!session.has('api_key')) {
// There is a session, but it's not valid
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroy(session),
},
});
}
if (
context.oidc &&
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 = true;
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');
}
}
@@ -86,13 +45,31 @@ export async function loader({
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 context.client.healthcheck(),
};
} catch {
// No session, so we can just return
return redirect('/login');
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroySession(),
},
});
}
}
@@ -102,7 +79,8 @@ export default function Shell() {
return (
<>
<Header {...data} />
{data.uiAccess ? (
{/* Always show the outlet if we are onboarding */}
{(data.onboarding ? true : data.uiAccess) ? (
<Outlet />
) : (
<Card className="mx-auto w-fit mt-24">
@@ -114,11 +92,21 @@ 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=${data.url}`,
);
toast('Copied to clipboard');
}}
>
tailscale up --login-server={data.url}
</Button>
<p className="text-xs mt-1 opacity-50 text-center">
Click this button to copy the command.
</p>
<p className="mt-4 text-sm opacity-50">
Your account does not have access to the UI. Please contact your
administrator if you believe this is a mistake.
+2 -7
View File
@@ -1,8 +1,4 @@
import type {
LinksFunction,
LoaderFunctionArgs,
MetaFunction,
} from 'react-router';
import type { LinksFunction, MetaFunction } from 'react-router';
import {
Links,
Meta,
@@ -18,8 +14,6 @@ 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';
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
@@ -47,6 +41,7 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
<link rel="icon" href="favicon.ico" />
</head>
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
{children}
+6 -3
View File
@@ -6,15 +6,17 @@ export default [
route('/healthz', 'routes/util/healthz.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 +24,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'),
]),
]),
+113
View File
@@ -0,0 +1,113 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles';
import { data400, data403 } from '~/utils/res';
// We only check capabilities here and assume it is writable
// If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening.
export async function aclAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_policy,
);
if (!check) {
throw data403('You do not have permission to write to the ACL policy');
}
// Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData();
const policyData = formData.get('policy')?.toString();
if (!policyData) {
throw data400('Missing `policy` in the form data.');
}
try {
const { policy, updatedAt } = await context.client.put<{
policy: string;
updatedAt: string;
}>('v1/policy', session.api_key, {
policy: policyData,
});
return data({
success: true,
error: undefined,
policy,
updatedAt,
});
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('update is disabled')) {
// This means the policy is not writable
throw data403('Policy is not writable');
}
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
if (message.includes('parsing hujson')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err: hujson:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('unmarshalling policy')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('empty policy')) {
return data(
{
success: false,
error: 'Policy error: Supplied policy was empty',
policy: undefined,
updatedAt: undefined,
},
400,
);
}
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}
+64
View File
@@ -0,0 +1,64 @@
import { LoaderFunctionArgs } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles';
import { data403 } from '~/utils/res';
// The logic for deciding policy factors is very complicated because
// there are so many factors that need to be accounted for:
// 1. Does the user have permission to read the policy?
// 2. Does the user have permission to write to the policy?
// 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available.
// TODO: Consider adding back file editing mode instead of database
export async function aclLoader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_policy);
if (!check) {
throw data403('You do not have permission to read the ACL policy.');
}
const flags = {
// Can the user write to the ACL policy
access: await context.sessions.check(request, Capabilities.write_policy),
writable: false,
policy: '',
};
// Try to load the ACL policy from the API.
try {
const { policy, updatedAt } = await context.client.get<{
policy: string;
updatedAt: string | null;
}>('v1/policy', session.api_key);
// Successfully loaded the policy, mark it as readable
// If `updatedAt` is null, it means the policy is in file mode.
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('acl policy not found')) {
// This means the policy has never been initiated, and we can
// write to it to get it started or ignore it.
flags.policy = ''; // Start with an empty policy
flags.writable = true;
}
return flags;
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}
+3
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(() => {
@@ -38,6 +39,8 @@ export function Editor(props: EditorProps) {
{() => (
<CodeMirror
value={props.value}
editable={!props.isDisabled} // Allow editing unless disabled
readOnly={props.isDisabled} // Use readOnly if disabled
height="100%"
extensions={[shopify.jsonc()]}
style={{ height: '100%' }}
-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
View File
@@ -1,4 +1,3 @@
import Spinner from '~/components/Spinner';
import cn from '~/utils/cn';
interface Props {
@@ -1,39 +0,0 @@
import { AlertIcon } from '@primer/octicons-react';
import cn from '~/utils/cn';
import Card from '~/components/Card';
import Code from '~/components/Code';
interface Props {
mode: 'file' | 'database';
}
export function Unavailable({ mode }: Props) {
return (
<Card variant="flat" className="max-w-prose mt-12">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">ACL Policy Unavailable</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Unable to load a valid ACL policy configuration. This is most likely due
to a misconfiguration in your Headscale configuration file.
</Card.Text>
{mode !== 'file' ? (
<p className="mt-4 text-sm">
According to your configuration, the ACL policy mode is set to{' '}
<Code>file</Code> but the ACL file is not available. Ensure that the{' '}
<Code>policy.path</Code> is set to a valid path in your Headscale
configuration.
</p>
) : (
<p className="mt-4 text-sm">
In order to fully utilize the ACL management features of Headplane,
please set <Code>policy.mode</Code> to either <Code>file</Code> or{' '}
<Code>database</Code> in your Headscale configuration.
</p>
)}
</Card>
);
}
-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>
);
}
+177
View File
@@ -0,0 +1,177 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
ActionFunctionArgs,
LoaderFunctionArgs,
useFetcher,
useLoaderData,
useRevalidator,
} from 'react-router';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import type { LoadContext } from '~/server';
import toast from '~/utils/toast';
import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client';
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
return aclLoader(request);
}
export async function action(request: ActionFunctionArgs<LoadContext>) {
return aclAction(request);
}
export default function Page() {
// Access is a write check here, we already check read in aclLoader
const { access, writable, policy } = useLoaderData<typeof loader>();
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
const disabled = !access || !writable; // Disable if no permission or not writable
useEffect(() => {
// Update the codePolicy when the loader data changes
if (policy !== codePolicy) {
setCodePolicy(policy);
}
}, [policy]);
useEffect(() => {
if (!fetcher.data) {
// No data yet, return
return;
}
if (fetcher.data.success === true) {
toast('Updated policy');
revalidate();
}
}, [fetcher.data]);
return (
<div>
{!access ? (
<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
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
>
Headscale docs
</Link>
.
</p>
{fetcher.data?.error !== undefined ? (
<Notice
variant="error"
title={fetcher.data.error.split(':')[0] ?? 'Error'}
>
{fetcher.data.error.split(':').slice(1).join(': ') ??
'An unknown error occurred while trying to update the ACL policy.'}
</Notice>
) : undefined}
<Tabs label="ACL Editor" className="mb-4">
<Tabs.Item
key="edit"
title={
<div className="flex items-center gap-2">
<Pencil className="p-1" />
<span>Edit file</span>
</div>
}
>
<Editor
isDisabled={disabled}
value={codePolicy}
onChange={setCodePolicy}
/>
</Tabs.Item>
<Tabs.Item
key="diff"
title={
<div className="flex items-center gap-2">
<Eye className="p-1" />
<span>Preview changes</span>
</div>
}
>
<Differ left={policy} right={codePolicy} />
</Tabs.Item>
<Tabs.Item
key="preview"
title={
<div className="flex items-center gap-2">
<FlaskConical className="p-1" />
<span>Preview rules</span>
</div>
}
>
<div className="flex flex-col items-center py-8">
<Construction />
<p className="w-1/2 text-center mt-4">
Previewing rules is not available yet. This feature is still in
development and is pretty complicated to implement. Hopefully I
will be able to get to it soon.
</p>
</div>
</Tabs.Item>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={
disabled ||
fetcher.state !== 'idle' ||
codePolicy.length === 0 ||
codePolicy === policy
}
onPress={() => {
const formData = new FormData();
formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' });
}}
>
Save
</Button>
<Button
isDisabled={
disabled || fetcher.state !== 'idle' || codePolicy === policy
}
onPress={() => {
// Reset the editor to the original policy
setCodePolicy(policy);
}}
>
Discard Changes
</Button>
</div>
);
}
-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>
);
}
+104
View File
@@ -0,0 +1,104 @@
import { ActionFunctionArgs, data, redirect } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Key } from '~/types';
import log from '~/utils/log';
export async function loginAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
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 });
}
try {
const { apiKeys } = await context.client.get<{ apiKeys: Key[] }>(
'v1/apikey',
apiKey,
);
// We don't need to check for 0 API keys because this request cannot
// be authenticated correctly without an API key
const lookup = apiKeys.find((key) => apiKey.startsWith(key.prefix));
if (!lookup) {
return {
success: false,
message: 'API key was not found in the Headscale database',
};
}
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) {
if (error instanceof ResponseError) {
// TODO: What in gods name is wrong with the headscale API?
if (
error.status === 401 ||
error.status === 403 ||
(error.status === 500 && error.response.trim() === 'Unauthorized')
) {
return {
success: false,
message: 'API key is invalid (it may be incorrect or expired)',
};
}
}
log.error('auth', 'Error while validating API key: %s', error);
log.debug('auth', 'Error details: %o', error);
return {
success: false,
message: 'Error while validating API key (see logs for details)',
};
}
}
+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>
);
}
+132
View File
@@ -0,0 +1,132 @@
import { useEffect } from 'react';
import {
ActionFunctionArgs,
data,
Form,
LoaderFunctionArgs,
Link as RemixLink,
redirect,
useActionData,
useLoaderData,
useSearchParams,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import type { LoadContext } from '~/server';
import { useLiveData } from '~/utils/live-data';
import { loginAction } from './action';
import Logout from './logout';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try {
await context.sessions.auth(request);
return redirect('/machines');
} catch {}
const qp = new URL(request.url).searchParams;
const state = qp.get('s') ?? undefined;
// OIDC config cannot be undefined if an OIDC client is set
// Also check if we are in a logout state and skip redirect if we are
const ssoOnly = context.config.oidc?.disable_api_key_login;
if (state !== 'logout' && ssoOnly) {
// This shouldn't be possible, but still a safe sanity check
if (!context.oidc) {
throw data(
'`oidc.disable_api_key_login` was set without a valid OIDC configuration',
{
status: 400,
},
);
}
return redirect('/oidc/start');
}
return {
oidc: context.oidc,
state,
};
}
export async function action(request: ActionFunctionArgs<LoadContext>) {
return loginAction(request);
}
export default function Page() {
const { state, oidc } = useLoaderData<typeof loader>();
const formData = useActionData<typeof action>();
const [params] = useSearchParams();
const { pause } = useLiveData();
useEffect(() => {
// This page does NOT need stale while revalidate logic
pause();
});
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (state !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete('s');
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
window.history.replaceState(null, '', newUrl);
}
}, [state, params]);
if (state === 'logout') {
return <Logout />;
}
return (
<div className="flex w-screen h-screen items-center justify-center">
<Card className="max-w-md m-4 sm:m-0">
<Card.Title>Welcome to Headplane</Card.Title>
<Form method="POST">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
<Input
className="mt-8 mb-2"
isRequired
label="API Key"
labelHidden
name="api_key"
placeholder="API Key"
type="password"
/>
{formData?.success === false ? (
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
{formData.message}
</Card.Text>
) : undefined}
<Button className="w-full" type="submit" variant="heavy">
Sign In
</Button>
</Form>
{oidc ? (
<RemixLink to="/oidc/start">
<Button className="w-full mt-2" variant="light">
Single Sign-On
</Button>
</RemixLink>
) : undefined}
</Card>
</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(),
},
});
}
+81 -19
View File
@@ -1,9 +1,21 @@
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import { createHash } from 'node:crypto';
import { count, eq } from 'drizzle-orm';
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
import { ulid } from 'ulidx';
import type { LoadContext } from '~/server';
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { finishAuthFlow, formatError } from '~/utils/oidc';
import { HeadplaneConfig } from '~/server/config/schema';
import { users } from '~/server/db/schema';
import { Roles } from '~/server/web/roles';
import { FlowUser, finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
interface OidcFlowSession {
state: string;
nonce: string;
code_verifier: string;
redirect_uri: string;
}
export async function loader({
request,
context,
@@ -18,13 +30,21 @@ export async function loader({
return redirect('/login');
}
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if (session.get('state') !== 'flow') {
return redirect('/login'); // Haven't started an OIDC flow
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300, // 5 minutes
});
const data: OidcFlowSession | null = await cookie.parse(
request.headers.get('Cookie'),
);
if (data === null) {
console.warn('OIDC flow session not found');
return redirect('/login');
}
const payload = session.get('oidc')!;
const { code_verifier, state, nonce, redirect_uri } = payload;
const { code_verifier, state, nonce, redirect_uri } = data;
if (!code_verifier || !state || !nonce || !redirect_uri) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
}
@@ -42,20 +62,39 @@ export async function loader({
};
try {
const user = await finishAuthFlow(context.oidc, flowOptions);
session.unset('oidc');
const userSession = session as Session<AuthSession>;
let user = await finishAuthFlow(context.oidc, flowOptions);
user = {
...user,
picture: setOidcPictureForSource(
user,
context.config.oidc?.profile_picture_source ?? 'oidc',
),
};
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: user.subject,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
// 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),
'Set-Cookie': await context.sessions.createSession({
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
api_key: context.config.oidc?.headscale_api_key!,
user,
}),
},
});
} catch (error) {
@@ -67,3 +106,26 @@ export async function loader({
});
}
}
type PictureSource = NonNullable<
HeadplaneConfig['oidc']
>['profile_picture_source'];
function setOidcPictureForSource(user: FlowUser, source: PictureSource) {
// Already set by default in the callback, so we can just return it
if (source === 'oidc') {
return user.picture;
}
if (source === 'gravatar') {
if (!user.email) {
return undefined;
}
const emailHash = user.email.trim().toLowerCase();
const hash = createHash('sha256').update(emailHash).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
}
return undefined;
}
+18 -17
View File
@@ -1,42 +1,43 @@
import { type LoaderFunctionArgs, Session, redirect } from 'react-router';
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.getOrCreate<OidcFlowSession>(request);
if ((session as Session<AuthSession>).has('api_key')) {
try {
await context.sessions.auth(request);
return redirect('/machines');
}
} catch {}
if (!context.oidc) {
if (!context.oidc || !context.config.oidc) {
throw new Error('OIDC is not enabled');
}
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300, // 5 minutes
});
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,
context.config.oidc.scope,
context.config.oidc.extra_params,
);
session.set('state', 'flow');
session.set('oidc', {
state: data.state,
nonce: data.nonce,
code_verifier: data.codeVerifier,
redirect_uri: redirectUri,
});
return redirect(data.url, {
status: 302,
headers: {
'Set-Cookie': await context.sessions.commit(session),
'Set-Cookie': await cookie.serialize({
state: data.state,
nonce: data.nonce,
code_verifier: data.codeVerifier,
redirect_uri: redirectUri,
}),
},
});
}
+2 -2
View File
@@ -167,7 +167,7 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
ref={setNodeRef}
className={cn(
isSortableDragging ? 'opacity-50' : '',
isDragging ? 'ring bg-white dark:bg-headplane-900' : '',
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
)}
style={{
transform: CSS.Transform.toString(transform),
@@ -179,7 +179,7 @@ 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}
+58 -5
View File
@@ -1,16 +1,24 @@
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">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
@@ -31,6 +39,7 @@ export default function ManageNS({ nameservers, isDisabled }: Props) {
isGlobal={key === 'global'}
isDisabled={isDisabled}
nameservers={nameservers}
overrideLocalDns={overrideLocalDns}
name={key}
/>
))}
@@ -45,6 +54,7 @@ interface ListProps {
isGlobal: boolean;
isDisabled: boolean;
nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
name: string;
}
@@ -52,6 +62,7 @@ function NameserverList({
isGlobal,
isDisabled,
nameservers,
overrideLocalDns,
name,
}: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name];
@@ -59,12 +70,54 @@ function NameserverList({
return null;
}
const submit = useSubmit();
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 items-center justify-between w-full">
<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
to="https://tailscale.com/kb/1054/dns#global-nameservers"
name="Tailscale Global Nameservers Documentation"
>
Learn More
</Link>
</Tooltip.Body>
</Tooltip>
<p>Override DNS servers</p>
<Switch
label="Override local DNS settings"
className="h-[15px] w-[23px] p-[2px]"
switchClassName="h-[9px] w-[9px]"
name="override_dns"
defaultSelected={overrideLocalDns}
onChange={(v) => {
submit(
{
action_id: 'override_dns',
override_dns: v ? 'true' : 'false',
},
{
method: 'POST',
},
);
}}
/>
</div>
</div>
) : (
<h2 className="text-md font-medium opacity-80">{name}</h2>
)}
</div>
<TableList>
{list.length > 0
+12 -12
View File
@@ -17,7 +17,7 @@ export default function ManageRecords({ records, isDisabled }: Props) {
<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"
@@ -34,19 +34,19 @@ export default function ManageRecords({ records, isDisabled }: Props) {
) : (
records.map((record, index) => (
<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}
</p>
<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="grid grid-cols-2 gap-2 w-full">
<p className="font-mono text-sm">{record.name}</p>
<p className="font-mono text-sm">{record.value}</p>
</div>
<p className="font-mono text-sm">{record.value}</p>
</div>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_record" />
+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}
+51 -23
View File
@@ -1,11 +1,20 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { hp_getIntegration } from '~/utils/integration/loader';
import { Capabilities } from '~/server/web/roles';
export async function dnsAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const check = await context.sessions.check(
request,
Capabilities.write_network,
);
if (!check) {
return data({ success: false }, 403);
}
if (!context.hs.writable()) {
return data({ success: false }, 403);
}
@@ -33,6 +42,8 @@ export async function dnsAction({
return removeRecord(formData, context);
case 'add_record':
return addRecord(formData, context);
case 'override_dns':
return overrideDns(formData, context);
default:
return data({ success: false }, 400);
}
@@ -51,7 +62,7 @@ async function renameTailnet(formData: FormData, context: LoadContext) {
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function toggleMagic(formData: FormData, context: LoadContext) {
@@ -67,7 +78,7 @@ async function toggleMagic(formData: FormData, context: LoadContext) {
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function removeNs(formData: FormData, context: LoadContext) {
@@ -95,12 +106,12 @@ async function removeNs(formData: FormData, context: LoadContext) {
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
value: servers.length > 0 ? servers : null,
},
]);
}
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function addNs(formData: FormData, context: LoadContext) {
@@ -135,7 +146,7 @@ async function addNs(formData: FormData, context: LoadContext) {
]);
}
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function removeDomain(formData: FormData, context: LoadContext) {
@@ -153,7 +164,7 @@ async function removeDomain(formData: FormData, context: LoadContext) {
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function addDomain(formData: FormData, context: LoadContext) {
@@ -173,7 +184,7 @@ async function addDomain(formData: FormData, context: LoadContext) {
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function removeRecord(formData: FormData, context: LoadContext) {
@@ -185,18 +196,18 @@ async function removeRecord(formData: FormData, context: LoadContext) {
return data({ success: false }, 400);
}
const records = config.dns.extra_records.filter(
(i) => i.name !== recordName || i.type !== recordType,
);
// Value is not needed for removal
const restart = await context.hs.removeDNS({
name: recordName,
type: recordType,
value: '',
});
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
},
]);
if (!restart) {
return;
}
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
async function addRecord(formData: FormData, context: LoadContext) {
@@ -209,15 +220,32 @@ async function addRecord(formData: FormData, context: LoadContext) {
return data({ success: false }, 400);
}
const records = config.dns.extra_records;
records.push({ name: recordName, type: recordType, value: recordValue });
const restart = await context.hs.addDNS({
name: recordName,
type: recordType,
value: recordValue,
});
if (!restart) {
return;
}
await context.integration?.onConfigChange(context.client);
}
async function overrideDns(formData: FormData, context: LoadContext) {
const override = formData.get('override_dns')?.toString();
if (!override) {
return data({ success: false }, 400);
}
const overrideValue = override === 'true';
await context.hs.patch([
{
path: 'dns.extra_records',
value: records,
path: 'dns.override_local_dns',
value: overrideValue,
},
]);
await hp_getIntegration()?.onConfigChange();
await context.integration?.onConfigChange(context.client);
}
+37 -5
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,18 +68,28 @@ 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>
)}
{data.access ? undefined : (
<Notice>
Your permissions do not allow you to modify the DNS settings for this
tailnet.
</Notice>
)}
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
<ManageNS nameservers={allNs} isDisabled={isDisabled} />
<ManageNS
nameservers={allNs}
isDisabled={isDisabled}
overrideLocalDns={data.overrideDns}
/>
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
<ManageDomains
searchDomains={data.searchDomains}
-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,222 @@
import { ChevronDown, Copy } from 'lucide-react';
import { useMemo } from 'react';
import { Link } from 'react-router';
import Chip from '~/components/Chip';
import Menu from '~/components/Menu';
import StatusCircle from '~/components/StatusCircle';
import { ExitNodeTag } from '~/components/tags/ExitNode';
import { ExpiryTag } from '~/components/tags/Expiry';
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
import { SubnetTag } from '~/components/tags/Subnet';
import { TailscaleSSHTag } from '~/components/tags/TailscaleSSH';
import type { User } from '~/types';
import cn from '~/utils/cn';
import * as hinfo from '~/utils/host-info';
import { PopulatedNode } from '~/utils/node-info';
import toast from '~/utils/toast';
import MenuOptions from './menu';
interface Props {
node: PopulatedNode;
users: User[];
isAgent?: boolean;
magic?: string;
isDisabled?: boolean;
}
export default function MachineRow({
node,
users,
isAgent,
magic,
isDisabled,
}: Props) {
const uiTags = useMemo(() => {
const tags = uiTagsForNode(node, isAgent);
return tags;
}, [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="pl-0.5 py-2 focus-within:ring-3">
<Link
className={cn('group/link h-full focus:outline-hidden')}
to={`/machines/${node.id}`}
>
<p
className={cn(
'font-semibold leading-snug',
'group-hover/link:text-blue-600',
'dark:group-hover/link:text-blue-400',
)}
>
{node.givenName}
</p>
<p className="text-sm opacity-50">
{node.user.name || node.user.displayName || node.user.email || node.user.id}
</p>
<div className="flex gap-1 flex-wrap mt-1.5">
{mapTagsToComponents(node, uiTags)}
{node.validTags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
</Link>
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{node.ipAddresses[0]}
<Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses">
<ChevronDown className="w-4 h-4" />
</Menu.IconButton>
<Menu.Panel
onAction={async (key) => {
await navigator.clipboard.writeText(key.toString());
toast('Copied IP address to clipboard');
}}
>
<Menu.Section>
{ipOptions.map((ip) => (
<Menu.Item key={ip} textValue={ip}>
<div
className={cn(
'flex items-center justify-between',
'text-sm w-full gap-x-6',
)}
>
{ip}
<Copy className="w-3 h-3" />
</div>
</Menu.Item>
))}
</Menu.Section>
</Menu.Panel>
</Menu>
</div>
</td>
{/* We pass undefined when agents are not enabled */}
{isAgent !== undefined ? (
<td className="py-2">
{node.hostInfo !== undefined ? (
<>
<p className="leading-snug">
{hinfo.getTSVersion(node.hostInfo)}
</p>
<p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(node.hostInfo)}
</p>
</>
) : (
<p className="text-sm opacity-50">Unknown</p>
)}
</td>
) : undefined}
<td className="py-2">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-headplane-600 dark:text-headplane-300',
)}
>
<StatusCircle
className="w-4 h-4"
isOnline={node.online && !node.expired}
/>
<p suppressHydrationWarning>
{node.online && !node.expired
? 'Connected'
: new Date(node.lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
isDisabled={isDisabled}
magic={magic}
node={node}
users={users}
/>
</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>
);
}
+70 -33
View File
@@ -1,46 +1,42 @@
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';
import Rename from '../dialogs/rename';
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;
}
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
export default function MachineMenu({
machine,
routes,
node,
magic,
users,
isFullButton,
isDisabled,
}: 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}
machine={node}
isOpen={modal === 'remove'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
@@ -49,7 +45,7 @@ export default function MachineMenu({
)}
{modal === 'move' && (
<Move
machine={machine}
machine={node}
users={users}
isOpen={modal === 'move'}
setIsOpen={(isOpen) => {
@@ -59,7 +55,7 @@ export default function MachineMenu({
)}
{modal === 'rename' && (
<Rename
machine={machine}
machine={node}
magic={magic}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
@@ -69,8 +65,7 @@ export default function MachineMenu({
)}
{modal === 'routes' && (
<Routes
machine={machine}
routes={routes}
node={node}
isOpen={modal === 'routes'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
@@ -79,16 +74,16 @@ export default function MachineMenu({
)}
{modal === 'tags' && (
<Tags
machine={machine}
machine={node}
isOpen={modal === 'tags'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
/>
)}
{expired && modal === 'expire' ? undefined : (
{node.expired && modal === 'expire' ? undefined : (
<Expire
machine={machine}
machine={node}
isOpen={modal === 'expire'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
@@ -96,7 +91,50 @@ export default function MachineMenu({
/>
)}
<Menu>
{supportsTailscaleSSH ? (
isFullButton ? (
<Button
className="flex items-center gap-x-2"
variant="heavy"
onPress={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.name}`,
'_blank',
'noopener,noreferrer,width=800,height=600',
);
}}
>
<SquareTerminal className="h-5" />
<p>SSH</p>
</Button>
) : (
<Button
onPress={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.name}`,
'_blank',
'noopener,noreferrer,width=800,height=600',
);
}}
className={cn(
'py-0.5 w-fit bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
'opacity-0 pointer-events-none group-hover:opacity-100',
'group-hover:pointer-events-auto',
)}
>
SSH
</Button>
)
) : undefined}
<Menu isDisabled={isDisabled}>
{isFullButton ? (
<Menu.Button className="flex items-center gap-x-2">
<Cog className="h-5" />
@@ -114,7 +152,10 @@ export default function MachineMenu({
<Ellipsis className="h-5" />
</Menu.IconButton>
)}
<Menu.Panel onAction={(key) => setModal(key as Modal)}>
<Menu.Panel
onAction={(key) => setModal(key as Modal)}
disabledKeys={node.expired ? ['expire'] : []}
>
<Menu.Section>
<Menu.Item key="rename">Edit machine name</Menu.Item>
<Menu.Item key="routes">Edit route settings</Menu.Item>
@@ -122,19 +163,15 @@ export default function MachineMenu({
<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>
);
}
+2 -2
View File
@@ -22,8 +22,8 @@ export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
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 type="hidden" name="action_id" value="delete" />
<input type="hidden" name="node_id" 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>
);
+12 -4
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,6 +11,8 @@ interface MoveProps {
}
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
const [userId, setUserId] = useState<Key | null>(null);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
@@ -17,16 +20,21 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
<Dialog.Text>
The owner of the machine is the user associated with it.
</Dialog.Text>
<input type="hidden" name="_method" value="move" />
<input type="hidden" name="id" value={machine.id} />
<input type="hidden" name="action_id" value="reassign" />
<input type="hidden" name="node_id" value={machine.id} />
<input type="hidden" name="user_id" value={userId?.toString()} />
<Select
isRequired
label="Owner"
name="to"
name="user"
placeholder="Select a user"
defaultSelectedKey={machine.user.id}
onSelectionChange={(key) => {
setUserId(key);
}}
>
{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>
+12 -14
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,12 +8,14 @@ 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();
@@ -25,20 +27,16 @@ export default function New(data: NewProps) {
<Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4">
The machine key is given when you run{' '}
<Code isCopyable>
tailscale up --login-server=
{data.server}
</Code>{' '}
on your device.
<Code isCopyable>tailscale up --login-server={data.server}</Code> on
your device.
</Dialog.Text>
<input type="hidden" name="_method" value="register" />
<input type="hidden" name="id" value="_" />
<input type="hidden" name="action_id" value="register" />
<Input
isRequired
label="Machine Key"
placeholder="AbCd..."
validationBehavior="native"
name="mkey"
name="register_key"
onChange={setMkey}
/>
<Select
@@ -48,12 +46,12 @@ 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 isDisabled={data.isDisabled} disabledKeys={data.disabledKeys}>
<Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel
onAction={(key) => {
@@ -76,7 +74,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>
+33 -2
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 type="hidden" name="action_id" value="rename" />
<input type="hidden" name="node_id" value={machine.id} />
<Input
isRequired
label="Machine name"
placeholder="Machine name"
validationBehavior="native"
name="name"
defaultValue={machine.givenName}
onChange={setName}
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';
}
}}
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
+26 -46
View File
@@ -1,55 +1,30 @@
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
@@ -62,7 +37,7 @@ export default function Routes({
</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, {
@@ -102,7 +77,7 @@ export default function Routes({
</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, {
+4 -4
View File
@@ -33,8 +33,8 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
</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="action_id" value="update_tags" />
<input type="hidden" name="node_id" value={machine.id} />
<input type="hidden" name="tags" value={tags.join(',')} />
<TableList className="mt-4">
{tags.length === 0 ? (
@@ -59,10 +59,10 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
)}
<TableList.Item
className={cn(
'rounded-b-xl focus-within:ring',
'rounded-b-xl focus-within:ring-3',
tag.length > 0 &&
(!tag.startsWith('tag:') || tags.includes(tag)) &&
'ring ring-red-500 ring-opacity-50',
'ring-3 ring-red-500 ring-opacity-50',
)}
>
<Input
+266
View File
@@ -0,0 +1,266 @@
import { type ActionFunctionArgs, data, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { Machine } from '~/types';
export async function machineAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_machines,
);
const formData = await request.formData();
const apiKey = session.api_key;
const action = formData.get('action_id')?.toString();
if (!action) {
throw data('Missing `action_id` in the form data.', {
status: 400,
});
}
// Fast track register since it doesn't require an existing machine
if (action === 'register') {
if (!check) {
throw data('You do not have permission to manage machines', {
status: 403,
});
}
return registerMachine(formData, apiKey, context);
}
// Check if the user has permission to manage this machine
const nodeId = formData.get('node_id')?.toString();
if (!nodeId) {
throw data('Missing `node_id` in the form data.', {
status: 400,
});
}
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
apiKey,
);
const node = nodes.find((node) => node.id === nodeId);
if (!node) {
throw data(`Machine with ID ${nodeId} not found`, {
status: 404,
});
}
if (
node.user.providerId?.split('/').pop() !== session.user.subject &&
!check
) {
throw data('You do not have permission to act on this machine', {
status: 403,
});
}
switch (action) {
case 'rename': {
return renameMachine(formData, apiKey, nodeId, context);
}
case 'delete': {
return deleteMachine(apiKey, nodeId, context);
}
case 'expire': {
return expireMachine(apiKey, nodeId, context);
}
case 'update_tags': {
return updateTags(formData, apiKey, nodeId, context);
}
case 'update_routes': {
return updateRoutes(formData, apiKey, nodeId, context);
}
case 'reassign': {
return reassignMachine(formData, apiKey, nodeId, context);
}
default:
throw data('Invalid action', {
status: 400,
});
}
}
async function registerMachine(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const registrationKey = formData.get('register_key')?.toString();
if (!registrationKey) {
throw data('Missing `register_key` in the form data.', {
status: 400,
});
}
const user = formData.get('user')?.toString();
if (!user) {
throw data('Missing `user` in the form data.', {
status: 400,
});
}
const qp = new URLSearchParams();
qp.append('user', user);
qp.append('key', registrationKey);
const url = `v1/node/register?${qp.toString()}`;
const { node } = await context.client.post<{ node: Machine }>(url, apiKey, {
user,
key: registrationKey,
});
return redirect(`/machines/${node.id}`);
}
async function renameMachine(
formData: FormData,
apiKey: string,
nodeId: string,
context: LoadContext,
) {
const newName = formData.get('name')?.toString();
if (!newName) {
throw data('Missing `name` in the form data.', {
status: 400,
});
}
const name = String(formData.get('name'));
await context.client.post(`v1/node/${nodeId}/rename/${name}`, apiKey);
return { message: 'Machine renamed' };
}
async function deleteMachine(
apiKey: string,
nodeId: string,
context: LoadContext,
) {
await context.client.delete(`v1/node/${nodeId}`, apiKey);
return redirect('/machines');
}
async function expireMachine(
apiKey: string,
nodeId: string,
context: LoadContext,
) {
await context.client.post(`v1/node/${nodeId}/expire`, apiKey);
return { message: 'Machine expired' };
}
async function updateTags(
formData: FormData,
apiKey: string,
nodeId: string,
context: LoadContext,
) {
const tags = formData.get('tags')?.toString().split(',') ?? [];
if (tags.length === 0) {
throw data('Missing `tags` in the form data.', {
status: 400,
});
}
await context.client.post(`v1/node/${nodeId}/tags`, apiKey, {
tags: tags.map((tag) => tag.trim()).filter((tag) => tag !== ''),
});
return { message: 'Tags updated' };
}
async function updateRoutes(
formData: FormData,
apiKey: string,
nodeId: string,
context: LoadContext,
) {
const { node } = await context.client.get<{ node: Machine }>(
`v1/node/${nodeId}`,
apiKey,
);
const newApproved = node.approvedRoutes;
const routes = formData.get('routes')?.toString();
if (!routes) {
throw data('Missing `routes` in the form data.', {
status: 400,
});
}
const allRoutes = routes.split(',').map((route) => route.trim());
if (allRoutes.length === 0) {
throw data('No routes provided to update', {
status: 400,
});
}
const enabled = formData.get('enabled')?.toString();
if (enabled === undefined) {
throw data('Missing `enabled` in the form data.', {
status: 400,
});
}
if (enabled === 'true') {
for (const route of allRoutes) {
// If already approved, skip, otherwise add to approved
if (newApproved.includes(route)) {
continue;
}
newApproved.push(route);
}
} else {
for (const route of allRoutes) {
// If not approved, skip, otherwise remove from approved
if (!newApproved.includes(route)) {
continue;
}
const index = newApproved.indexOf(route);
if (index > -1) {
newApproved.splice(index, 1);
}
}
}
await context.client.post(`v1/node/${nodeId}/approve_routes`, apiKey, {
routes: newApproved,
});
return { message: 'Routes updated' };
}
async function reassignMachine(
formData: FormData,
apiKey: string,
nodeId: string,
context: LoadContext,
) {
const user = formData.get('user_id')?.toString();
if (!user) {
throw data('Missing `user_id` in the form data.', {
status: 400,
});
}
await context.client.post(`v1/node/${nodeId}/user`, apiKey, {
user,
});
return { message: 'Machine reassigned' };
}
+213 -138
View File
@@ -10,11 +10,14 @@ import Link from '~/components/Link';
import StatusCircle from '~/components/StatusCircle';
import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import type { Machine, Route, User } from '~/types';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import { menuAction } from './action';
import { getOSInfo, getTSVersion } from '~/utils/host-info';
import { mapNodes } from '~/utils/node-info';
import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
import MenuOptions from './components/menu';
import Routes from './dialogs/routes';
import { machineAction } from './machine-actions';
export async function loader({
request,
@@ -33,101 +36,52 @@ export async function loader({
}
}
const [machine, routes, users] = await Promise.all([
const [machine, { users }] = await Promise.all([
context.client.get<{ node: Machine }>(
`v1/node/${params.id}`,
session.get('api_key')!,
session.api_key,
),
context.client.get<{ routes: Route[] }>(
'v1/routes',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
context.client.get<{ users: User[] }>('v1/user', session.api_key),
]);
const lookup = await context.agents?.lookup([machine.node.nodeKey]);
const [node] = mapNodes([machine.node], lookup);
const tags = Array.from(
new Set([...node.validTags, ...node.forcedTags]),
).sort();
return {
machine: machine.node,
routes: routes.routes.filter((route) => route.node.id === params.id),
users: users.users,
node,
tags,
users,
magic,
// TODO: Fix agent
agent: false,
// agent: [...(hp_getSingletonUnsafe('ws_agents') ?? []).keys()].includes(
// machine.node.id,
// ),
agent: context.agents?.agentID(),
stats: lookup?.[node.nodeKey],
};
}
export async function action(request: ActionFunctionArgs) {
return menuAction(request);
return machineAction(request);
}
export default function Page() {
const { machine, magic, routes, users, agent } =
const { node, tags, magic, users, agent, stats } =
useLoaderData<typeof loader>();
const [showRouting, setShowRouting] = useState(false);
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');
}
if (agent) {
tags.unshift('Headplane Agent');
}
// This is much easier with Object.groupBy but it's too new for us
const { exit, subnet, subnetApproved } = routes.reduce<{
exit: Route[];
subnet: Route[];
subnetApproved: 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');
}
const uiTags = useMemo(() => {
const tags = uiTagsForNode(node, agent === node.nodeKey);
return tags;
}, [node, agent]);
return (
<div>
<p className="mb-8 text-md">
<RemixLink to="/machines" className="font-medium">
<RemixLink className="font-medium" to="/machines">
All Machines
</RemixLink>
<span className="mx-2">/</span>
{machine.givenName}
{node.givenName}
</p>
<div
className={cn(
@@ -136,17 +90,10 @@ export default function Page() {
)}
>
<span className="flex items-baseline gap-x-4 text-sm">
<h1 className="text-2xl font-medium">{machine.givenName}</h1>
<StatusCircle isOnline={machine.online} className="w-4 h-4" />
<h1 className="text-2xl font-medium">{node.givenName}</h1>
<StatusCircle className="w-4 h-4" isOnline={node.online} />
</span>
<MenuOptions
isFullButton
machine={machine}
routes={routes}
users={users}
magic={magic}
/>
<MenuOptions isFullButton magic={magic} node={node} users={users} />
</div>
<div className="flex gap-1 mb-4">
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
@@ -161,35 +108,32 @@ export default function Page() {
</span>
<div className="flex items-center gap-x-2.5 mt-1">
<UserCircle />
{machine.user.name}
{node.user.name ||
node.user.displayName ||
node.user.email ||
node.user.id}
</div>
</div>
{tags.length > 0 ? (
<div className="p-2 pl-4">
<p className="text-sm text-headplane-600 dark:text-headplane-300">
Status
</p>
<div className="flex gap-1 mt-1 mb-8">
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
<div className="p-2 pl-4">
<p className="text-sm text-headplane-600 dark:text-headplane-300">
Status
</p>
<div className="flex gap-1 mt-1 mb-8">
{mapTagsToComponents(node, uiTags)}
{tags.map((tag) => (
<Chip key={tag} text={tag} />
))}
</div>
) : undefined}
</div>
</div>
<h2 className="text-xl font-medium mb-4 mt-8">Subnets & Routing</h2>
<Routes
machine={machine}
routes={routes}
isOpen={showRouting}
setIsOpen={setShowRouting}
/>
<Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
<div className="flex items-center justify-between mb-4">
<p>
Subnets let you expose physical network routes onto Tailscale.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
to="https://tailscale.com/kb/1019/subnets"
>
Learn More
</Link>
@@ -197,11 +141,11 @@ export default function Page() {
<Button onPress={() => setShowRouting(true)}>Review</Button>
</div>
<Card
variant="flat"
className={cn(
'w-full max-w-full grid sm:grid-cols-2',
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
)}
variant="flat"
>
<div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
@@ -214,22 +158,22 @@ export default function Page() {
</Tooltip>
</span>
<div className="mt-1">
{subnetApproved.length === 0 ? (
{node.customRouting.subnetApprovedRoutes.length === 0 ? (
<span className="opacity-50"></span>
) : (
<ul className="leading-normal">
{subnetApproved.map((route) => (
<li key={route.id}>{route.prefix}</li>
{node.customRouting.subnetApprovedRoutes.map((route) => (
<li key={route}>{route}</li>
))}
</ul>
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
@@ -246,22 +190,22 @@ export default function Page() {
</Tooltip>
</span>
<div className="mt-1">
{subnet.length === 0 ? (
{node.customRouting.subnetWaitingRoutes.length === 0 ? (
<span className="opacity-50"></span>
) : (
<ul className="leading-normal">
{subnet.map((route) => (
<li key={route.id}>{route.prefix}</li>
{node.customRouting.subnetWaitingRoutes.map((route) => (
<li key={route}>{route}</li>
))}
</ul>
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
@@ -277,9 +221,9 @@ export default function Page() {
</Tooltip>
</span>
<div className="mt-1">
{exit.length === 0 ? (
{node.customRouting.exitRoutes.length === 0 ? (
<span className="opacity-50"></span>
) : exitEnabled ? (
) : node.customRouting.exitApproved ? (
<span className="flex items-center gap-x-1">
<CheckCircle className="w-3.5 h-3.5 text-green-700" />
Allowed
@@ -292,43 +236,174 @@ export default function Page() {
)}
</div>
<Button
onPress={() => setShowRouting(true)}
className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400',
)}
onPress={() => setShowRouting(true)}
>
Edit
</Button>
</div>
</Card>
<h2 className="text-xl font-medium mb-4">Machine Details</h2>
<Card variant="flat" className="w-full max-w-full">
<Attribute name="Creator" value={machine.user.name} />
<Attribute name="Node ID" value={machine.id} />
<Attribute name="Node Name" value={machine.givenName} />
<Attribute name="Hostname" value={machine.name} />
<Attribute isCopyable name="Node Key" value={machine.nodeKey} />
<Attribute
name="Created"
value={new Date(machine.createdAt).toLocaleString()}
/>
<Attribute
name="Last Seen"
value={new Date(machine.lastSeen).toLocaleString()}
/>
<Attribute
name="Expiry"
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
/>
{magic ? (
<h2 className="text-xl font-medium">Machine Details</h2>
<p className="mb-4">
Information about this machines network. Used to debug connection
issues.
</p>
<Card
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12"
variant="flat"
>
<div className="flex flex-col gap-1">
<Attribute
name="Creator"
value={
node.user.name ||
node.user.displayName ||
node.user.email ||
node.user.id
}
/>
<Attribute name="Machine name" value={node.givenName} />
<Attribute
name="OS hostname"
tooltip="OS hostname is published by the machines operating system and is used as the default name for the machine."
value={node.name}
/>
{stats ? (
<>
<Attribute name="OS" value={getOSInfo(stats)} />
<Attribute name="Tailscale version" value={getTSVersion(stats)} />
</>
) : undefined}
<Attribute
name="ID"
tooltip="ID for this machine. Used in the Headscale API."
value={node.id}
/>
<Attribute
isCopyable
name="Domain"
value={`${machine.givenName}.${magic}`}
name="Node key"
tooltip="Public key which uniquely identifies this machine."
value={node.nodeKey}
/>
) : undefined}
<Attribute
name="Created"
value={new Date(node.createdAt).toLocaleString()}
/>
<Attribute
name="Last Seen"
value={
node.online
? 'Connected'
: new Date(node.lastSeen).toLocaleString()
}
/>
<Attribute
name="Key expiry"
value={
node.expiry !== null
? new Date(node.expiry).toLocaleString()
: 'Never'
}
/>
{magic ? (
<Attribute
isCopyable
name="Domain"
value={`${node.givenName}.${magic}`}
/>
) : undefined}
</div>
<div className="flex flex-col gap-1">
<p className="uppercase text-sm font-semibold opacity-75">
Addresses
</p>
<Attribute
isCopyable
name="Tailscale IPv4"
tooltip="This machines IPv4 address within your tailnet (your private Tailscale network)."
value={getIpv4Address(node.ipAddresses)}
/>
<Attribute
isCopyable
name="Tailscale IPv6"
tooltip="This machines IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
value={getIpv6Address(node.ipAddresses)}
/>
<Attribute
isCopyable
name="Short domain"
tooltip="Users of your tailnet can use this DNS short name to access this machine."
value={node.givenName}
/>
{magic ? (
<Attribute
isCopyable
name="Full domain"
tooltip="Users of your tailnet can use this DNS name to access this machine."
value={`${node.givenName}.${magic}`}
/>
) : undefined}
{stats ? (
<>
<p className="uppercase text-sm font-semibold opacity-75 mt-4">
Client Connectivity
</p>
<Attribute
name="Varies"
tooltip="Whether the machine is behind a difficult NAT that varies the machines IP address depending on the destination."
value={stats.NetInfo?.MappingVariesByDestIP ? 'Yes' : 'No'}
/>
<Attribute
name="Hairpinning"
tooltip="Whether the machine needs to traverse NATs with hairpinning."
value={stats.NetInfo?.HairPinning ? 'Yes' : 'No'}
/>
<Attribute
name="IPv6"
value={stats.NetInfo?.WorkingIPv6 ? 'Yes' : 'No'}
/>
<Attribute
name="UDP"
value={stats.NetInfo?.WorkingUDP ? 'Yes' : 'No'}
/>
<Attribute
name="UPnP"
value={stats.NetInfo?.UPnP ? 'Yes' : 'No'}
/>
<Attribute name="PCP" value={stats.NetInfo?.PCP ? 'Yes' : 'No'} />
<Attribute
name="NAT-PMP"
value={stats.NetInfo?.PMP ? 'Yes' : 'No'}
/>
</>
) : undefined}
</div>
</Card>
</div>
);
}
function getIpv4Address(addresses: string[]) {
for (const address of addresses) {
if (address.startsWith('100.')) {
// Return the first CGNAT address
return address;
}
}
return '—';
}
function getIpv6Address(addresses: string[]) {
for (const address of addresses) {
if (address.startsWith('fd')) {
// Return the first IPv6 address
return address;
}
}
return '—';
}
+59 -38
View File
@@ -1,32 +1,48 @@
import { InfoIcon } from '@primer/octicons-react';
import { Info } from 'lucide-react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import Code from '~/components/Code';
import { ErrorPopup } from '~/components/Error';
import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import type { Machine, Route, User } from '~/types';
import { Capabilities } from '~/server/web/roles';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import { menuAction } from './action';
import MachineRow from './components/machine';
import { mapNodes } from '~/utils/node-info';
import MachineRow from './components/machine-row';
import NewMachine from './dialogs/new';
import { machineAction } from './machine-actions';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const [machines, routes, users] = await Promise.all([
context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.get('api_key')!,
),
context.client.get<{ routes: Route[] }>(
'v1/routes',
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
const user = session.user;
if (!user) {
throw new Error('Missing user session. Please log in again.');
}
const check = await context.sessions.check(
request,
Capabilities.read_machines,
);
if (!check) {
// Not authorized to view this page
throw new Error(
'You do not have permission to view this page. Please contact your administrator.',
);
}
const writablePermission = await context.sessions.check(
request,
Capabilities.write_machines,
);
const [{ nodes }, { users }] = await Promise.all([
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
context.client.get<{ users: User[] }>('v1/user', session.api_key),
]);
let magic: string | undefined;
@@ -36,20 +52,28 @@ export async function loader({
}
}
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
const populatedNodes = mapNodes(nodes, stats);
return {
nodes: machines.nodes,
routes: routes.routes,
users: users.users,
populatedNodes,
nodes,
users,
magic,
server: context.config.headscale.url,
publicServer: context.config.headscale.public_url,
agents: context.agents?.tailnetIDs(),
stats: context.agents?.lookup(machines.nodes.map((node) => node.nodeKey)),
agent: context.agents?.agentID(),
writable: writablePermission,
preAuth: await context.sessions.check(
request,
Capabilities.generate_authkeys,
),
subject: user.subject,
};
}
export async function action(request: ActionFunctionArgs) {
return menuAction(request);
return machineAction(request);
}
export default function Page() {
@@ -63,14 +87,16 @@ export default function Page() {
<p>
Manage the devices connected to your Tailnet.{' '}
<Link
to="https://tailscale.com/kb/1372/manage-devices"
name="Tailscale Manage Devices Documentation"
to="https://tailscale.com/kb/1372/manage-devices"
>
Learn more
</Link>
</p>
</div>
<NewMachine
disabledKeys={data.preAuth ? [] : ['pre-auth']}
isDisabled={!data.writable}
server={data.publicServer ?? data.server}
users={data.users}
/>
@@ -84,7 +110,7 @@ export default function Page() {
<p className="uppercase text-xs font-bold">Addresses</p>
{data.magic ? (
<Tooltip>
<InfoIcon className="w-4 h-4" />
<Info className="w-4 h-4" />
<Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based on
their name and also at{' '}
@@ -98,7 +124,7 @@ export default function Page() {
</div>
</th>
{/* We only want to show the version column if there are agents */}
{data.agents !== undefined ? (
{data.agent !== undefined ? (
<th className="uppercase text-xs font-bold pb-2">Version</th>
) : undefined}
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
@@ -110,19 +136,18 @@ export default function Page() {
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{data.nodes.map((machine) => (
{data.populatedNodes.map((machine) => (
<MachineRow
isAgent={data.agent ? data.agent === machine.nodeKey : undefined}
isDisabled={
data.writable
? false // If the user has write permissions, they can edit all machines
: machine.user.providerId?.split('/').pop() !== data.subject
}
key={machine.id}
machine={machine}
routes={data.routes.filter(
(route) => route.node.id === machine.id,
)}
users={data.users}
magic={data.magic}
// If we pass undefined, the column will not be rendered
// This is useful for when there are no agents configured
isAgent={data.agents?.includes(machine.id)}
stats={data.stats?.[machine.nodeKey]}
node={machine}
users={data.users}
/>
))}
</tbody>
@@ -130,7 +155,3 @@ export default function Page() {
</>
);
}
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}
-214
View File
@@ -1,214 +0,0 @@
import { useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import { Link as RemixLink } from 'react-router';
import Link from '~/components/Link';
import Select from '~/components/Select';
import TableList from '~/components/TableList';
import type { LoadContext } from '~/server';
import type { PreAuthKey, User } from '~/types';
import { send } from '~/utils/res';
import AuthKeyRow from './components/key';
import AddPreAuthKey from './dialogs/new';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const users = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
const preAuthKeys = await Promise.all(
users.users.map((user) => {
const qp = new URLSearchParams();
qp.set('user', user.name);
return context.client.get<{ preAuthKeys: PreAuthKey[] }>(
`v1/preauthkey?${qp.toString()}`,
session.get('api_key')!,
);
}),
);
return {
keys: preAuthKeys.flatMap((keys) => keys.preAuthKeys),
users: users.users,
server: context.config.headscale.public_url ?? context.config.headscale.url,
};
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const data = await request.formData();
// Expiring a pre-auth key
if (request.method === 'DELETE') {
const key = data.get('key');
const user = data.get('user');
if (!key || !user) {
return send(
{ message: 'Missing parameters' },
{
status: 400,
},
);
}
await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey/expire',
session.get('api_key')!,
{
user: user,
key: key,
},
);
return { message: 'Pre-auth key expired' };
}
// Creating a new pre-auth key
if (request.method === 'POST') {
const user = data.get('user');
const expiry = data.get('expiry');
const reusable = data.get('reusable');
const ephemeral = data.get('ephemeral');
if (!user || !expiry || !reusable || !ephemeral) {
return send(
{ message: 'Missing parameters' },
{
status: 400,
},
);
}
// Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(' ')[0]);
const date = new Date();
date.setDate(date.getDate() + day);
const key = await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.get('api_key')!,
{
user: user,
ephemeral: ephemeral === 'on',
reusable: reusable === 'on',
expiration: date.toISOString(),
aclTags: [], // TODO
},
);
return { message: 'Pre-auth key created', key };
}
}
export default function Page() {
const { keys, users, server } = useLoaderData<typeof loader>();
const [user, setUser] = useState('__headplane_all');
const [status, setStatus] = useState('active');
const filteredKeys = keys.filter((key) => {
if (user !== '__headplane_all' && key.user !== user) {
return false;
}
if (status !== 'all') {
const now = new Date();
const expiry = new Date(key.expiration);
if (status === 'active') {
return !(expiry < now) && (!key.used || key.reusable);
}
if (status === 'expired') {
return key.used || expiry < now;
}
if (status === 'reusable') {
return key.reusable;
}
if (status === 'ephemeral') {
return key.ephemeral;
}
}
return true;
});
// TODO: Fix the selects
return (
<div className="flex flex-col w-2/3">
<p className="mb-8 text-md">
<RemixLink to="/settings" className="font-medium">
Settings
</RemixLink>
<span className="mx-2">/</span> Pre-Auth Keys
</p>
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1>
<p className="mb-4">
Headscale fully supports pre-authentication keys in order to easily add
devices to your Tailnet. To learn more about using pre-authentication
keys, visit the{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
>
Tailscale documentation
</Link>
</p>
<AddPreAuthKey users={users} />
<div className="flex items-center gap-4 mt-4">
<Select
label="Filter by User"
placeholder="Select a user"
className="w-full"
defaultSelectedKey="__headplane_all"
onSelectionChange={(value) => setUser(value?.toString() ?? '')}
>
{[
<Select.Item key="__headplane_all">All</Select.Item>,
...users.map((user) => (
<Select.Item key={user.name}>{user.name}</Select.Item>
)),
]}
</Select>
<Select
label="Filter by status"
placeholder="Select a status"
className="w-full"
defaultSelectedKey="active"
onSelectionChange={(value) => setStatus(value?.toString() ?? '')}
>
<Select.Item key="all">All</Select.Item>
<Select.Item key="active">Active</Select.Item>
<Select.Item key="expired">Used/Expired</Select.Item>
<Select.Item key="reusable">Reusable</Select.Item>
<Select.Item key="ephemeral">Ephemeral</Select.Item>
</Select>
</div>
<TableList className="mt-4">
{filteredKeys.length === 0 ? (
<TableList.Item>
<p className="opacity-50 text-sm mx-auto">No pre-auth keys</p>
</TableList.Item>
) : (
filteredKeys.map((key) => (
<TableList.Item key={key.id}>
<AuthKeyRow authKey={key} server={server} />
</TableList.Item>
))
)}
</TableList>
</div>
);
}
+118
View File
@@ -0,0 +1,118 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { PreAuthKey } from '~/types';
export async function authKeysAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.generate_authkeys,
);
if (!check) {
throw data('You do not have permission to manage pre-auth keys', {
status: 403,
});
}
const formData = await request.formData();
const apiKey = session.api_key;
const action = formData.get('action_id')?.toString();
if (!action) {
throw data('Missing `action_id` in the form data.', {
status: 400,
});
}
switch (action) {
case 'add_preauthkey':
return await addPreAuthKey(formData, apiKey, context);
case 'expire_preauthkey':
return await expirePreAuthKey(formData, apiKey, context);
default:
return data('Invalid action', {
status: 400,
});
}
}
async function addPreAuthKey(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const user = formData.get('user_id')?.toString();
if (!user) {
return data('Missing `user_id` in the form data.', {
status: 400,
});
}
const expiry = formData.get('expiry')?.toString();
if (!expiry) {
return data('Missing `expiry` in the form data.', {
status: 400,
});
}
const reusable = formData.get('reusable')?.toString();
if (!reusable) {
return data('Missing `reusable` in the form data.', {
status: 400,
});
}
const ephemeral = formData.get('ephemeral')?.toString();
if (!ephemeral) {
return data('Missing `ephemeral` in the form data.', {
status: 400,
});
}
// Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(' ')[0]);
const date = new Date();
date.setDate(date.getDate() + day);
await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
apiKey,
{
user,
ephemeral: ephemeral === 'on',
reusable: reusable === 'on',
expiration: date.toISOString(),
aclTags: [], // TODO
},
);
return data('Pre-auth key created');
}
async function expirePreAuthKey(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const key = formData.get('key')?.toString();
if (!key) {
return data('Missing `key` in the form data.', {
status: 400,
});
}
const user = formData.get('user_id')?.toString();
if (!user) {
return data('Missing `user_id` in the form data.', {
status: 400,
});
}
await context.client.post('v1/preauthkey/expire', apiKey, { user, key });
return data('Pre-auth key expired');
}
@@ -1,24 +1,28 @@
import type { PreAuthKey } from '~/types';
import Attribute from '~/components/Attribute';
import Button from '~/components/Button';
import Code from '~/components/Code';
import type { PreAuthKey, User } from '~/types';
import toast from '~/utils/toast';
import ExpireKey from '../dialogs/expire';
import ExpireAuthKey from './dialogs/expire-auth-key';
interface Props {
authKey: PreAuthKey;
server: string;
user: User;
url: string;
}
export default function AuthKeyRow({ authKey, server }: Props) {
export default function AuthKeyRow({ authKey, user, url }: Props) {
const createdAt = new Date(authKey.createdAt).toLocaleString();
const expiration = new Date(authKey.expiration).toLocaleString();
return (
<div className="w-full">
<Attribute name="Key" value={authKey.key} isCopyable />
<Attribute name="User" value={authKey.user} isCopyable />
<Attribute isCopyable name="Key" value={authKey.key} />
<Attribute
isCopyable
name="User"
value={user.name || user.displayName || user.email || user.id}
/>
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
@@ -28,23 +32,23 @@ export default function AuthKeyRow({ authKey, server }: Props) {
To use this key, run the following command on your device:
</p>
<Code className="text-sm">
tailscale up --login-server {server} --authkey {authKey.key}
tailscale up --login-server={url} --authkey {authKey.key}
</Code>
<div className="flex gap-4 items-center">
<div className="flex gap-4 items-center" suppressHydrationWarning>
{(authKey.used && !authKey.reusable) ||
new Date(authKey.expiration) < new Date() ? undefined : (
<ExpireKey authKey={authKey} />
<ExpireAuthKey authKey={authKey} user={user} />
)}
<Button
variant="light"
className="my-4"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server ${server} --authkey ${authKey.key}`,
`tailscale up --login-server=${url} --authkey ${authKey.key}`,
);
toast('Copied command to clipboard');
}}
variant="light"
>
Copy Tailscale Command
</Button>
@@ -1,4 +1,4 @@
import { useState } from 'react';
import { Key, useState } from 'react';
import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
@@ -7,30 +7,36 @@ import Select from '~/components/Select';
import Switch from '~/components/Switch';
import type { User } from '~/types';
interface Props {
interface AddAuthKeyProps {
users: User[];
}
// TODO: Tags
export default function AddPreAuthKey(data: Props) {
export default function AddAuthKey(data: AddAuthKeyProps) {
const [reusable, setReusable] = useState(false);
const [ephemeral, setEphemeral] = useState(false);
const [userId, setUserId] = useState<Key | null>(data.users[0]?.id);
return (
<Dialog>
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Generate auth key</Dialog.Title>
<Dialog.Text className="font-semibold">User</Dialog.Text>
<Dialog.Text className="text-sm">Attach this key to a user</Dialog.Text>
<input type="hidden" name="action_id" value="add_preauthkey" />
<input type="hidden" name="user_id" value={userId?.toString()} />
<Select
isRequired
label="Owner"
label="User"
name="user"
placeholder="Select a user"
description="This is the user machines will belong to when they authenticate."
className="mb-2"
onSelectionChange={(value) => {
setUserId(value);
}}
>
{data.users.map((user) => (
<Select.Item key={user.name}>{user.name}</Select.Item>
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
))}
</Select>
<NumberInput
@@ -47,7 +53,7 @@ export default function AddPreAuthKey(data: Props) {
unitDisplay: 'short',
}}
/>
<div className="flex justify-between items-center mt-6">
<div className="flex justify-between items-center gap-2 mt-6">
<div>
<Dialog.Text className="font-semibold">Reusable</Dialog.Text>
<Dialog.Text className="text-sm">
@@ -64,7 +70,7 @@ export default function AddPreAuthKey(data: Props) {
/>
</div>
<input type="hidden" name="reusable" value={reusable.toString()} />
<div className="flex justify-between items-center mt-6">
<div className="flex justify-between items-center gap-2 mt-6">
<div>
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
<Dialog.Text className="text-sm">
@@ -1,17 +1,19 @@
import Dialog from '~/components/Dialog';
import type { PreAuthKey } from '~/types';
import type { PreAuthKey, User } from '~/types';
interface Props {
interface ExpireAuthKeyProps {
authKey: PreAuthKey;
user: User;
}
export default function ExpireKey({ authKey }: Props) {
export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
return (
<Dialog>
<Dialog.Button>Expire Key</Dialog.Button>
<Dialog.Panel method="DELETE" variant="destructive">
<Dialog.Button variant="heavy">Expire Key</Dialog.Button>
<Dialog.Panel variant="destructive">
<Dialog.Title>Expire auth key?</Dialog.Title>
<input type="hidden" name="user" value={authKey.user} />
<input type="hidden" name="action_id" value="expire_preauthkey" />
<input type="hidden" name="user_id" value={user.id} />
<input type="hidden" name="key" value={authKey.key} />
<Dialog.Text>
Expiring this authentication key will immediately prevent it from
+256
View File
@@ -0,0 +1,256 @@
import { FileKey2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { Link as RemixLink, useLoaderData } from 'react-router';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Select from '~/components/Select';
import TableList from '~/components/TableList';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import type { PreAuthKey, User } from '~/types';
import log from '~/utils/log';
import { authKeysAction } from './actions';
import AuthKeyRow from './auth-key-row';
import AddAuthKey from './dialogs/add-auth-key';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.api_key,
);
const preAuthKeys = await Promise.all(
users
.filter((user) => user.name?.length > 0) // Filter out any invalid users
.map(async (user) => {
const qp = new URLSearchParams();
qp.set('user', user.id);
try {
const { preAuthKeys } = await context.client.get<{
preAuthKeys: PreAuthKey[];
}>(`v1/preauthkey?${qp.toString()}`, session.api_key);
return {
success: true,
user,
preAuthKeys,
};
} catch (error) {
log.error('api', 'GET /v1/preauthkey for %s: %o', user.name, error);
return {
success: false,
user,
error,
preAuthKeys: [] as PreAuthKey[],
};
}
}),
);
const keys = preAuthKeys
.filter(({ success }) => success)
.map(({ user, preAuthKeys }) => ({
user,
preAuthKeys,
}));
const missing = preAuthKeys
.filter(({ success }) => !success)
.map(({ user, error }) => ({
user,
error,
}));
return {
keys,
missing,
users,
access: await context.sessions.check(
request,
Capabilities.generate_authkeys,
),
url: context.config.headscale.public_url ?? context.config.headscale.url,
};
}
export async function action(request: ActionFunctionArgs<LoadContext>) {
return authKeysAction(request);
}
type Status = 'all' | 'active' | 'expired' | 'reusable' | 'ephemeral';
export default function Page() {
const { keys, missing, users, url, access } = useLoaderData<typeof loader>();
const [selectedUser, setSelectedUser] = useState('__headplane_all');
const [status, setStatus] = useState<Status>('active');
const isDisabled =
!access || keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0;
const filteredKeys = useMemo(() => {
const now = new Date();
return keys
.filter(({ user }) => {
if (selectedUser === '__headplane_all') {
return true;
}
return user.id === selectedUser;
})
.flatMap(({ preAuthKeys }) => preAuthKeys)
.filter((key) => {
if (status === 'all') {
return true;
}
if (status === 'ephemeral') {
return key.ephemeral;
}
if (status === 'reusable') {
return key.reusable;
}
const expiry = new Date(key.expiration);
if (status === 'expired') {
// Expired keys are either used or expired
// BUT only used if they are not reusable
if (key.used && !key.reusable) {
return true;
}
return expiry < now;
}
if (status === 'active') {
// Active keys are either not expired or reusable
if (expiry < now) {
return false;
}
if (!key.used) {
return true;
}
return key.reusable;
}
return false;
});
}, [keys, selectedUser, status]);
return (
<div className="flex flex-col md:w-2/3">
<p className="mb-8 text-md">
<RemixLink className="font-medium" to="/settings">
Settings
</RemixLink>
<span className="mx-2">/</span> Pre-Auth Keys
</p>
{!access ? (
<Notice title="Pre-auth key permissions restricted" variant="warning">
You do not have the necessary permissions to generate pre-auth keys.
Please contact your administrator to request access or to generate a
pre-auth key for you.
</Notice>
) : missing.length > 0 ? (
<Notice title="Missing authentication keys" variant="error">
An error occurred while fetching the authentication keys for the
following users:{' '}
{missing.map(({ user }, index) => (
<>
<Code key={user.name}>{user.name}</Code>
{index < missing.length - 1 ? ', ' : '. '}
</>
))}
Their keys may not be listed correctly. Please check the server logs
for more information.
</Notice>
) : undefined}
<h1 className="text-2xl font-medium mb-2">Pre-Auth Keys</h1>
<p className="mb-4">
Headscale fully supports pre-authentication keys in order to easily add
devices to your Tailnet. To learn more about using pre-authentication
keys, visit the{' '}
<Link
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
Tailscale documentation
</Link>
</p>
<AddAuthKey users={users} />
<div className="flex items-center gap-4 mt-4">
<Select
className="w-full"
defaultSelectedKey="__headplane_all"
isDisabled={isDisabled}
label="User"
onSelectionChange={(value) =>
setSelectedUser(value?.toString() ?? '')
}
placeholder="Select a user"
>
{[
<Select.Item key="__headplane_all">All</Select.Item>,
...keys.map(({ user }) => (
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
)),
]}
</Select>
<Select
className="w-full"
defaultSelectedKey="active"
isDisabled={isDisabled}
label="Status"
onSelectionChange={(value) =>
setStatus((value?.toString() ?? 'active') as Status)
}
placeholder="Select a status"
>
<Select.Item key="all">All</Select.Item>
<Select.Item key="active">Active</Select.Item>
<Select.Item key="expired">Used/Expired</Select.Item>
<Select.Item key="reusable">Reusable</Select.Item>
<Select.Item key="ephemeral">Ephemeral</Select.Item>
</Select>
</div>
<TableList className="mt-4">
{keys.flatMap(({ preAuthKeys }) => preAuthKeys).length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<FileKey2 />
<p className="font-semibold">
No pre-auth keys have been created yet.
</p>
</TableList.Item>
) : filteredKeys.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
<FileKey2 />
<p className="font-semibold">
No pre-auth keys match the selected filters.
</p>
</TableList.Item>
) : (
filteredKeys.map((key) => {
// TODO: Why is Headscale using email as the user ID here?
// https://github.com/juanfont/headscale/issues/2520
const user = users.find((user) => user.id === key.user.id);
if (!user) {
return null;
}
return (
<TableList.Item key={key.id}>
<AuthKeyRow authKey={key} url={url} user={user} />
</TableList.Item>
);
})
)}
</TableList>
</div>
);
}
-33
View File
@@ -1,33 +0,0 @@
import { ArrowRightIcon } from '@primer/octicons-react';
import { Link as RemixLink } from 'react-router';
import Button from '~/components/Button';
import Link from '~/components/Link';
import cn from '~/utils/cn';
export default function AgentSection() {
return (
<>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Local Agent</h1>
<p>
Headplane provides a local agent that can be installed on a server to
provide additional features including viewing device information and
SSH access via the web interface (soon). To learn more about the agent
visit the{' '}
<Link
to="https://github.com/tale/headplane/blob/main/docs/Headplane-Agent.md"
name="Headplane Agent Documentation"
>
Headplane documentation
</Link>
</p>
</div>
<RemixLink to="/settings/local-agent">
<div className={cn('text-lg font-medium flex items-center')}>
Manage Agent
<ArrowRightIcon className="w-5 h-5 ml-2" />
</div>
</RemixLink>
</>
);
}
@@ -1,39 +0,0 @@
import Card from '~/components/Card';
import StatusCircle from '~/components/StatusCircle';
import type { HostInfo } from '~/types';
import * as hinfo from '~/utils/host-info';
export type Props = {
reachable: boolean;
hostInfo: HostInfo;
};
export default function AgentManagement({ reachable, hostInfo }: Props) {
console.log('hostInfo:', hostInfo);
return (
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Local Agent Configuration</h1>
<p className="mb-8">
A local agent has already been configured for this Headplane instance.
You can manage the agent settings here.
</p>
<Card>
<div className="flex items-center gap-2">
<StatusCircle isOnline={reachable} className="w-4 h-4 px-1" />
<div>
<p className="text-lg font-bold">
{hostInfo.Hostname ?? 'Unknown'}
</p>
<p className="leading-snug">
{hinfo.getTSVersion(hostInfo)}
<span className="ml-2 text-sm text-headplane-600 dark:text-headplane-300">
{hinfo.getOSInfo(hostInfo)}
</span>
</p>
</div>
</div>
{JSON.stringify(hostInfo)}
</Card>
</div>
);
}
-42
View File
@@ -1,42 +0,0 @@
import { useMemo } from 'react';
import { type LoaderFunctionArgs, useLoaderData } from 'react-router';
import { commitSession, getSession } from '~/utils/sessions.server';
import { queryAgent } from '~/utils/ws-agent';
import AgentManagement from './components/agent/manage';
export async function loader({ request, context }: LoaderFunctionArgs) {
const { ws, wsAuthKey } = context;
const session = await getSession(request.headers.get('Cookie'));
const onboarding = session.get('agent_onboarding') ?? false;
const nodeKey =
'nodekey:542dad28354eb8d51e240aada7adf0222ba3ecc74af0bbd56123f03eefdb391b';
const stats = await queryAgent([nodeKey]);
return {
configured: wsAuthKey !== undefined,
onboarding,
stats: stats?.[nodeKey],
};
}
export default function Page() {
const data = useLoaderData<typeof loader>();
// Whether we show the onboarding or management UI
const management = useMemo(() => {
return data.configured && data.onboarding === false;
}, [data.configured, data.onboarding]);
return (
<div className="flex flex-col gap-8 max-w-screen-lg">
{management ? (
<AgentManagement reachable={true} hostInfo={data.stats} />
) : (
<div>
<h1>Local Agent Coming Soon</h1>
</div>
)}
</div>
);
}
+46 -9
View File
@@ -1,14 +1,24 @@
import { ArrowRightIcon } from '@primer/octicons-react';
import { Link as RemixLink } from 'react-router';
import Button from '~/components/Button';
import { ArrowRight } from 'lucide-react';
import {
LoaderFunctionArgs,
Link as RemixLink,
useLoaderData,
} from 'react-router';
import Link from '~/components/Link';
import cn from '~/utils/cn';
import { LoadContext } from '~/server';
import AgentSection from './components/agent';
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
return {
config: context.hs.writable(),
oidc: context.oidc,
};
}
export default function Page() {
const { config, oidc } = useLoaderData<typeof loader>();
return (
<div className="flex flex-col gap-8 max-w-screen-lg">
<div className="flex flex-col gap-8 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Settings</h1>
<p>
@@ -24,8 +34,8 @@ export default function Page() {
add devices to your Tailnet. To learn more about using
pre-authentication keys, visit the{' '}
<Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
>
Tailscale documentation
</Link>
@@ -34,10 +44,37 @@ export default function Page() {
<RemixLink to="/settings/auth-keys">
<div className="text-lg font-medium flex items-center">
Manage Auth Keys
<ArrowRightIcon className="w-5 h-5 ml-2" />
<ArrowRight className="w-5 h-5 ml-2" />
</div>
</RemixLink>
{/**<AgentSection />**/}
{config && oidc ? (
<>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">
Authentication Restrictions
</h1>
<p>
Headscale supports restricting OIDC authentication to only allow
certain email domains, groups, or users to authenticate. This can
be used to limit access to your Tailnet to only certain users or
groups and Headplane will also respect these settings when
authenticating.{' '}
<Link
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
Learn More
</Link>
</p>
</div>
<RemixLink to="/settings/restrictions">
<div className="text-lg font-medium flex items-center">
Manage Restrictions
<ArrowRight className="w-5 h-5 ml-2" />
</div>
</RemixLink>
</>
) : undefined}
</div>
);
}
+221
View File
@@ -0,0 +1,221 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
export async function restrictionAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const check = await context.sessions.check(
request,
Capabilities.configure_iam,
);
if (!check) {
throw data('You do not have permission to modify IAM settings.', {
status: 403,
});
}
if (!context.hs.writable()) {
throw data('The Headscale configuration file is not editable.', {
status: 403,
});
}
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
throw data('No action provided.', {
status: 400,
});
}
switch (action) {
case 'add_domain': {
return addDomain(formData, context);
}
case 'remove_domain': {
return removeDomain(formData, context);
}
case 'add_group': {
return addGroup(formData, context);
}
case 'remove_group': {
return removeGroup(formData, context);
}
case 'add_user': {
return addUser(formData, context);
}
case 'remove_user': {
return removeUser(formData, context);
}
default: {
throw data('Invalid action provided.', {
status: 400,
});
}
}
}
async function addDomain(formData: FormData, context: LoadContext) {
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const domains = [
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
];
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(context.client);
return data('Domain added successfully.');
}
async function removeDomain(formData: FormData, context: LoadContext) {
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
if (!storedDomains.includes(domain)) {
// Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, {
status: 400,
});
}
// Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain);
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(context.client);
return data('Domain removed successfully.');
}
async function addUser(formData: FormData, context: LoadContext) {
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
const users = [
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
];
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
context.integration?.onConfigChange(context.client);
return data('User added successfully.');
}
async function removeUser(formData: FormData, context: LoadContext) {
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
if (!storedUsers.includes(user)) {
// User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
status: 400,
});
}
// Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user);
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
context.integration?.onConfigChange(context.client);
return data('User removed successfully.');
}
async function addGroup(formData: FormData, context: LoadContext) {
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const groups = [
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
];
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
context.integration?.onConfigChange(context.client);
return data('Group added successfully.');
}
async function removeGroup(formData: FormData, context: LoadContext) {
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
if (!storedGroups.includes(group)) {
// Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
status: 400,
});
}
// Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group);
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
context.integration?.onConfigChange(context.client);
return data('Group removed successfully.');
}

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