Compare commits

...

213 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
211 changed files with 14541 additions and 7130 deletions
-1
View File
@@ -1 +0,0 @@
use_flake
+3
View File
@@ -0,0 +1,3 @@
* @tale
/nix @tale @StealthBadger747
+20 -15
View File
@@ -1,6 +1,12 @@
name: Build
on:
push:
paths-ignore:
- ".zed/**"
- "assets/**"
- "docs/**"
- "CHANGELOG.md"
- "README.md"
branches:
- "main"
- "next"
@@ -22,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
@@ -46,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
+6
View File
@@ -4,3 +4,9 @@ node_modules
/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"
}
}
}
}
}
+57
View File
@@ -1,3 +1,60 @@
### 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.
+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
}
}
+55 -10
View File
@@ -1,18 +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
FROM node:22-alpine
RUN mkdir -p /var/lib/headplane
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
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()
}
+39 -28
View File
@@ -1,46 +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;
suppressHydrationWarning?: boolean;
}
export default function Attribute({
name,
value,
link,
tooltip,
isCopyable,
suppressHydrationWarning,
}: 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
suppressHydrationWarning={suppressHydrationWarning}
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) {
@@ -48,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) {
@@ -57,17 +65,20 @@ export default function Attribute({
}, 1000);
}}
>
<p
suppressHydrationWarning={suppressHydrationWarning}
className="truncate"
>
<div suppressHydrationWarning className="truncate">
{value}
</p>
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" />
<Copy className="h-4.5 w-4.5 p-1 block data-[copied]:hidden" />
</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',
)}
>
+14 -14
View File
@@ -1,14 +1,15 @@
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';
interface Props {
type?: 'full' | 'embedded';
}
function getMessage(error: Error | unknown): {
export function getErrorMessage(error: Error | unknown): {
title: string;
message: string;
} {
@@ -45,10 +46,7 @@ function getMessage(error: Error | unknown): {
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
return {
title: 'Errors',
message: rootError.errors.map((error) => error.message).join('\n'),
};
throw new Error('Unhandled AggregateError');
}
return {
@@ -60,7 +58,7 @@ function getMessage(error: Error | unknown): {
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const { title, message } = getMessage(error);
const { title, message } = getErrorMessage(error);
return (
<div
@@ -72,16 +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 : title}
</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>
<hr className="my-4 text-headplane-100 dark:text-headplane-800" />
<Card.Text
className={cn('mt-4 text-lg', routing ? 'font-normal' : 'font-mono')}
className={cn('py-4 text-lg', routing ? 'font-normal' : 'font-mono')}
>
{routing ? error.data.message : message}
{routing ? error.data : message}
</Card.Text>
</Card>
</div>
+53 -31
View File
@@ -1,49 +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{' '}
<button
type="button"
tabIndex={0} // Allows keyboard focus
className={cn(
'blur-sm hover:blur-none focus:blur-none transition',
'focus:outline-none focus:ring-2 rounded-sm',
)}
>
{url}
</button>
{/* Connecting to <strong className="blur-xs hover:blur-none">{url}</strong> */}
{debug && ' (Debug mode enabled)'}
</p>
<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>
);
}
+22 -22
View File
@@ -2,13 +2,13 @@ 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';
@@ -42,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>
@@ -64,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>
@@ -91,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">
@@ -101,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(
@@ -126,7 +127,6 @@ export default function Header(data: Props) {
);
}
}}
disabledKeys={['profile']}
>
<Menu.Section>
<Menu.Item key="profile" textValue="Profile">
@@ -148,39 +148,39 @@ export default function Header(data: Props) {
<nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
{data.access.machines ? (
<TabLink
to="/machines"
name="Machines"
icon={<Server className="w-5" />}
name="Machines"
to="/machines"
/>
) : undefined}
{data.access.users ? (
<TabLink
to="/users"
name="Users"
icon={<Users className="w-5" />}
name="Users"
to="/users"
/>
) : undefined}
{data.access.policy ? (
<TabLink
to="/acls"
name="Access Control"
icon={<Lock className="w-5" />}
name="Access Control"
to="/acls"
/>
) : undefined}
{data.configAvailable ? (
<>
{data.access.dns ? (
<TabLink
to="/dns"
name="DNS"
icon={<Globe2 className="w-5" />}
name="DNS"
to="/dns"
/>
) : undefined}
{data.access.settings ? (
<TabLink
to="/settings"
name="Settings"
icon={<Settings className="w-5" />}
name="Settings"
to="/settings"
/>
) : undefined}
</>
+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} />;
}
+2 -2
View File
@@ -74,7 +74,7 @@ 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
@@ -149,7 +149,7 @@ function MenuItem<T>({ item, state, isDisabled }: 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,
+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>
);
}
+9 -32
View File
@@ -1,30 +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 { pruneEphemeralNodes } from '~/server/db/pruner';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
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(),
},
});
}
@@ -37,33 +37,10 @@ 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>
);
}
+20 -62
View File
@@ -1,3 +1,4 @@
import { eq } from 'drizzle-orm';
import { CircleCheckIcon } from 'lucide-react';
import {
LoaderFunctionArgs,
@@ -10,9 +11,8 @@ import Card from '~/components/Card';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import type { LoadContext } from '~/server';
import { users } from '~/server/db/schema';
import { Capabilities } from '~/server/web/roles';
import { User } from '~/types';
import log from '~/utils/log';
import toast from '~/utils/toast';
// This loads the bare minimum for the application to function
@@ -23,64 +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 = false;
try {
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.get('api_key')!,
);
if (users.length === 0) {
onboarded = false;
}
const user = users.find((u) => {
if (u.provider !== 'oidc') {
return false;
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
const sessionUser = session.get('user');
if (!sessionUser) {
return false;
}
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
return true;
}
return subject === sessionUser.subject;
});
if (user) {
onboarded = true;
}
} catch (e) {
// If we cannot lookup users, just assume our user is onboarded
log.debug('api', 'Failed to lookup users %o', e);
onboarded = true;
}
if (!onboarded) {
if (!user?.onboarded) {
return redirect('/onboarding');
}
}
@@ -91,7 +45,7 @@ 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),
@@ -108,10 +62,14 @@ export async function loader({
),
},
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(),
},
});
}
}
+1
View File
@@ -41,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}
+4 -2
View File
@@ -6,10 +6,11 @@ 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
@@ -28,7 +29,8 @@ export default [
...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'),
]),
]),
+1 -1
View File
@@ -31,7 +31,7 @@ export async function aclAction({
const { policy, updatedAt } = await context.client.put<{
policy: string;
updatedAt: string;
}>('v1/policy', session.get('api_key')!, {
}>('v1/policy', session.api_key, {
policy: policyData,
});
+1 -1
View File
@@ -34,7 +34,7 @@ export async function aclLoader({
const { policy, updatedAt } = await context.client.get<{
policy: string;
updatedAt: string | null;
}>('v1/policy', session.get('api_key')!);
}>('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.
-45
View File
@@ -1,45 +0,0 @@
import { AlertIcon } from '@primer/octicons-react';
import React from 'react';
import Card from '~/components/Card';
interface NoticeViewProps {
title: string;
children: React.ReactNode;
}
export function NoticeView({ children, title }: NoticeViewProps) {
return (
<Card variant="flat" className="max-w-2xl my-8">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">{title}</Card.Title>
<AlertIcon className="w-8 h-8 text-yellow-500" />
</div>
<Card.Text className="mt-4">{children}</Card.Text>
</Card>
);
}
interface ErrorViewProps {
children: string;
}
export function ErrorView({ children }: ErrorViewProps) {
const [title, ...rest] = children.split(':');
const formattedMessage = rest.length > 0 ? rest.join(':').trim() : children;
return (
<Card variant="flat" className="max-w-2xl mb-4">
<div className="flex items-center justify-between">
<Card.Title className="text-xl mb-0">
{title.trim() ?? 'Error'}
</Card.Title>
<AlertIcon className="w-8 h-8 text-red-500" />
</div>
<Card.Text className="mt-4">
Could not apply changes to the ACL policy:
<br />
<span className="font-mono">{formattedMessage}</span>
</Card.Text>
</Card>
);
}
+13 -9
View File
@@ -17,7 +17,6 @@ import toast from '~/utils/toast';
import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client';
import { ErrorView, NoticeView } from './components/error';
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
return aclLoader(request);
@@ -57,19 +56,19 @@ export default function Page() {
return (
<div>
{!access ? (
<NoticeView title="ACL Policy restricted">
<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.
</NoticeView>
</Notice>
) : !writable ? (
<NoticeView title="Read-only ACL Policy">
<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>acl.mode</Code> to <Code>database</Code> in your Headscale
configuration.
</NoticeView>
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">
@@ -91,7 +90,13 @@ export default function Page() {
.
</p>
{fetcher.data?.error !== undefined ? (
<ErrorView>{fetcher.data.error}</ErrorView>
<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
@@ -150,7 +155,6 @@ export default function Page() {
}
onPress={() => {
const formData = new FormData();
console.log(codePolicy);
formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' });
}}
-186
View File
@@ -1,186 +0,0 @@
import { useEffect } from 'react';
import {
type ActionFunctionArgs,
type LoaderFunctionArgs,
redirect,
useSearchParams,
} from 'react-router';
import { Form, useActionData, useLoaderData } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import type { LoadContext } from '~/server';
import type { Key } from '~/types';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const qp = new URL(request.url).searchParams;
const state = qp.get('s');
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) {
// Prevents automatic redirect loop if OIDC is enabled and API key login is disabled
// Since logging out would just log back in based on the redirects
if (state !== 'logout') {
return redirect('/oidc/start');
}
}
return {
oidc: context.oidc,
disableApiKeyLogin,
state,
};
}
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 { state, disableApiKeyLogin, oidc } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const [params] = useSearchParams();
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (state !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete('s');
// Replacing because it's not a navigation, just a cleanup of the URL
// We can't use the useSearchParams method since it revalidates
// which will trigger a full reload
const newUrl = searchParams.toString()
? `{${window.location.pathname}?${searchParams.toString()}`
: window.location.pathname;
window.history.replaceState(null, '', newUrl);
}
}, [state, params]);
if (state === 'logout') {
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>You have been logged out</Card.Title>
<Card.Text>
You can now close this window. If you would like to log in again,
please refresh the page.
</Card.Text>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="max-w-sm m-4 sm:m-0" variant="raised">
<Card.Title>Welcome to Headplane</Card.Title>
{!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}
{oidc ? (
<Form method="POST">
<input type="hidden" name="oidc-start" value="true" />
<Button
className="w-full mt-2"
variant={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>
);
}
+5 -4
View File
@@ -9,9 +9,10 @@ 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');
}
// When API key is disabled, we need to explicitly redirect
@@ -22,7 +23,7 @@ export async function action({
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}
+33 -14
View File
@@ -42,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);
}
@@ -104,7 +106,7 @@ async function removeNs(formData: FormData, context: LoadContext) {
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
value: servers.length > 0 ? servers : null,
},
]);
}
@@ -194,16 +196,16 @@ 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 context.integration?.onConfigChange(context.client);
}
@@ -218,13 +220,30 @@ 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,
},
]);
+8 -3
View File
@@ -44,7 +44,8 @@ export async function loader({
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 {
@@ -70,7 +71,7 @@ export default function Page() {
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
@@ -84,7 +85,11 @@ export default function Page() {
</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}
+111 -90
View File
@@ -1,122 +1,74 @@
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react';
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 type { HostInfo, Machine, Route, User } from '~/types';
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 {
machine: Machine;
routes: Route[];
node: PopulatedNode;
users: User[];
isAgent?: boolean;
magic?: string;
stats?: HostInfo;
isDisabled?: boolean;
}
export default function MachineRow({
machine,
routes,
node,
users,
isAgent,
magic,
stats,
isDisabled,
}: 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 uiTags = useMemo(() => {
const tags = uiTagsForNode(node, isAgent);
return tags;
}, [node, isAgent]);
const ipOptions = useMemo(() => {
if (magic) {
return [...machine.ipAddresses, `${machine.givenName}.${prefix}`];
return [...node.ipAddresses, `${node.givenName}.${magic}`];
}
return machine.ipAddresses;
}, [magic, machine.ipAddresses]);
return node.ipAddresses;
}, [magic, node.ipAddresses]);
return (
<tr
key={machine.id}
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
key={node.id}
>
<td className="pl-0.5 py-2 focus-within:ring">
<td className="pl-0.5 py-2 focus-within:ring-3">
<Link
to={`/machines/${machine.id}`}
className={cn('group/link h-full focus:outline-none')}
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',
'group-hover/link:dark:text-blue-400',
'dark:group-hover/link:text-blue-400',
)}
>
{machine.givenName}
{node.givenName}
</p>
<p className="text-sm font-mono opacity-50">{machine.name}</p>
<div className="flex gap-1 mt-1">
{tags.map((tag) => (
<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>
@@ -124,10 +76,10 @@ export default function MachineRow({
</td>
<td className="py-2">
<div className="flex items-center gap-x-1">
{machine.ipAddresses[0]}
{node.ipAddresses[0]}
<Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses">
<ChevronDownIcon className="w-4 h-4" />
<ChevronDown className="w-4 h-4" />
</Menu.IconButton>
<Menu.Panel
onAction={async (key) => {
@@ -145,7 +97,7 @@ export default function MachineRow({
)}
>
{ip}
<CopyIcon className="w-3 h-3" />
<Copy className="w-3 h-3" />
</div>
</Menu.Item>
))}
@@ -157,11 +109,13 @@ export default function MachineRow({
{/* We pass undefined when agents are not enabled */}
{isAgent !== undefined ? (
<td className="py-2">
{stats !== undefined ? (
{node.hostInfo !== undefined ? (
<>
<p className="leading-snug">{hinfo.getTSVersion(stats)}</p>
<p className="leading-snug">
{hinfo.getTSVersion(node.hostInfo)}
</p>
<p className="text-sm opacity-50 max-w-48 truncate">
{hinfo.getOSInfo(stats)}
{hinfo.getOSInfo(node.hostInfo)}
</p>
</>
) : (
@@ -177,25 +131,92 @@ export default function MachineRow({
)}
>
<StatusCircle
isOnline={machine.online && !expired}
className="w-4 h-4"
isOnline={node.online && !node.expired}
/>
<p suppressHydrationWarning>
{machine.online && !expired
{node.online && !node.expired
? 'Connected'
: new Date(machine.lastSeen).toLocaleString()}
: new Date(node.lastSeen).toLocaleString()}
</p>
</span>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
machine={machine}
routes={routes}
users={users}
magic={magic}
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;
}
});
}
+67 -32
View File
@@ -1,18 +1,18 @@
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;
@@ -22,27 +22,21 @@ interface MenuProps {
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);
@@ -51,7 +45,7 @@ export default function MachineMenu({
)}
{modal === 'move' && (
<Move
machine={machine}
machine={node}
users={users}
isOpen={modal === 'move'}
setIsOpen={(isOpen) => {
@@ -61,7 +55,7 @@ export default function MachineMenu({
)}
{modal === 'rename' && (
<Rename
machine={machine}
machine={node}
magic={magic}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
@@ -71,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);
@@ -81,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);
@@ -98,6 +91,49 @@ export default function MachineMenu({
/>
)}
{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">
@@ -116,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>
@@ -124,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>
+7 -7
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';
@@ -12,6 +12,7 @@ export interface NewMachineProps {
server: string;
users: User[];
isDisabled?: boolean;
disabledKeys?: string[];
}
export default function NewMachine(data: NewMachineProps) {
@@ -29,14 +30,13 @@ export default function NewMachine(data: NewMachineProps) {
<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
@@ -46,12 +46,12 @@ export default function NewMachine(data: NewMachineProps) {
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 isDisabled={data.isDisabled}>
<Menu isDisabled={data.isDisabled} disabledKeys={data.disabledKeys}>
<Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel
onAction={(key) => {
@@ -74,7 +74,7 @@ export default function NewMachine(data: NewMachineProps) {
</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
+222 -203
View File
@@ -1,11 +1,8 @@
import type { ActionFunctionArgs } from 'react-router';
import { type ActionFunctionArgs, data, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { Machine } from '~/types';
import log from '~/utils/log';
import { data400, data403, data404, send } from '~/utils/res';
// TODO: Clean this up like dns-actions and user-actions
export async function machineAction({
request,
context,
@@ -16,14 +13,33 @@ export async function machineAction({
Capabilities.write_machines,
);
const apiKey = session.get('api_key')!;
const formData = await request.formData();
const apiKey = session.api_key;
// TODO: Rename this to 'action_id' and 'node_id'
const action = formData.get('_method')?.toString();
const nodeId = formData.get('id')?.toString();
if (!action || !nodeId) {
return data400('Missing required parameters: _method and id');
const 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[] }>(
@@ -33,215 +49,218 @@ export async function machineAction({
const node = nodes.find((node) => node.id === nodeId);
if (!node) {
return data404(`Node with ID ${nodeId} not found`);
throw data(`Machine with ID ${nodeId} not found`, {
status: 404,
});
}
const subject = session.get('user')!.subject;
if (node.user.providerId?.split('/').pop() !== subject) {
if (!check) {
return data403('You do not have permission to act on this machine');
}
if (
node.user.providerId?.split('/').pop() !== session.user.subject &&
!check
) {
throw data('You do not have permission to act on this machine', {
status: 403,
});
}
// TODO: Split up into methods
switch (action) {
case 'rename': {
return renameMachine(formData, apiKey, nodeId, context);
}
case 'delete': {
await context.client.delete(`v1/node/${nodeId}`, session.get('api_key')!);
return { message: 'Machine removed' };
return deleteMachine(apiKey, nodeId, context);
}
case 'expire': {
await context.client.post(
`v1/node/${nodeId}/expire`,
session.get('api_key')!,
);
return { message: 'Machine expired' };
return expireMachine(apiKey, nodeId, context);
}
case 'rename': {
if (!formData.has('name')) {
return send(
{ message: 'No name provided' },
{
status: 400,
},
);
}
const name = String(formData.get('name'));
await context.client.post(
`v1/node/${nodeId}/rename/${name}`,
session.get('api_key')!,
);
return { message: 'Machine renamed' };
case 'update_tags': {
return updateTags(formData, apiKey, nodeId, context);
}
case 'routes': {
if (!formData.has('route') || !formData.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const route = String(formData.get('route'));
const enabled = formData.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await context.client.post(
`v1/routes/${route}/${postfix}`,
session.get('api_key')!,
);
return { message: 'Route updated' };
case 'update_routes': {
return updateRoutes(formData, apiKey, nodeId, context);
}
case 'exit-node': {
if (!formData.has('routes') || !formData.has('enabled')) {
return send(
{ message: 'No route or enabled provided' },
{
status: 400,
},
);
}
const routes = formData.get('routes')?.toString().split(',') ?? [];
const enabled = formData.get('enabled') === 'true';
const postfix = enabled ? 'enable' : 'disable';
await Promise.all(
routes.map(async (route) => {
await context.client.post(
`v1/routes/${route}/${postfix}`,
session.get('api_key')!,
);
}),
);
return { message: 'Exit node updated' };
case 'reassign': {
return reassignMachine(formData, apiKey, nodeId, context);
}
case 'move': {
if (!formData.has('to')) {
return send(
{ message: 'No destination provided' },
{
status: 400,
},
);
}
const to = String(formData.get('to'));
try {
await context.client.post(
`v1/node/${nodeId}/user`,
session.get('api_key')!,
{
user: to,
},
);
return { message: `Moved node ${nodeId} to ${to}` };
} catch (error) {
console.error(error);
return send(
{ message: `Failed to move node ${nodeId} to ${to}` },
{
status: 500,
},
);
}
}
case 'tags': {
const tags =
formData
.get('tags')
?.toString()
.split(',')
.filter((tag) => tag.trim() !== '') ?? [];
try {
await context.client.post(
`v1/node/${nodeId}/tags`,
session.get('api_key')!,
{
tags,
},
);
return { message: 'Tags updated' };
} catch (error) {
log.debug('api', 'Failed to update tags: %s', error);
return send(
{ message: 'Failed to update tags' },
{
status: 500,
},
);
}
}
case 'register': {
const key = formData.get('mkey')?.toString();
const user = formData.get('user')?.toString();
if (!key) {
return send(
{ message: 'No machine key provided' },
{
status: 400,
},
);
}
if (!user) {
return send(
{ message: 'No user provided' },
{
status: 400,
},
);
}
try {
const qp = new URLSearchParams();
qp.append('user', user);
qp.append('key', key);
const url = `v1/node/register?${qp.toString()}`;
await context.client.post(url, session.get('api_key')!, {
user,
key,
});
return {
success: true,
message: 'Machine registered',
};
} catch {
return send(
{
success: false,
message: 'Failed to register machine',
},
{
status: 500,
},
);
}
}
default: {
return send(
{ message: 'Invalid method' },
{
status: 400,
},
);
}
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' };
}
+211 -139
View File
@@ -10,8 +10,11 @@ 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 { 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';
@@ -33,28 +36,27 @@ 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],
};
}
@@ -63,71 +65,23 @@ export async function action(request: ActionFunctionArgs) {
}
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,46 +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
suppressHydrationWarning
name="Created"
value={new Date(machine.createdAt).toLocaleString()}
/>
<Attribute
suppressHydrationWarning
name="Last Seen"
value={new Date(machine.lastSeen).toLocaleString()}
/>
<Attribute
suppressHydrationWarning
name="Expiry"
value={expired ? new Date(machine.expiry).toLocaleString() : 'Never'}
/>
{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 '—';
}
+29 -39
View File
@@ -1,14 +1,14 @@
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 { Capabilities } from '~/server/web/roles';
import type { Machine, Route, User } from '~/types';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import { mapNodes } from '~/utils/node-info';
import MachineRow from './components/machine-row';
import NewMachine from './dialogs/new';
import { machineAction } from './machine-actions';
@@ -18,7 +18,7 @@ export async function loader({
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const user = session.get('user');
const user = session.user;
if (!user) {
throw new Error('Missing user session. Please log in again.');
}
@@ -40,16 +40,9 @@ export async function loader({
Capabilities.write_machines,
);
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 [{ 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;
@@ -59,16 +52,22 @@ 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,
};
}
@@ -88,17 +87,18 @@ 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}
isDisabled={!data.writable}
/>
</div>
<table className="table-auto w-full rounded-lg">
@@ -110,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{' '}
@@ -124,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>
@@ -136,24 +136,18 @@ export default function Page() {
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{data.nodes.map((machine) => (
{data.populatedNodes.map((machine) => (
<MachineRow
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]}
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}
magic={data.magic}
node={machine}
users={data.users}
/>
))}
</tbody>
@@ -161,7 +155,3 @@ export default function Page() {
</>
);
}
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
}
-216
View File
@@ -1,216 +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
.filter((user) => user.name?.length > 0) // Filter out any invalid 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,54 +1,54 @@
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'} />
<Attribute suppressHydrationWarning name="Created" value={createdAt} />
<Attribute
suppressHydrationWarning
name="Expiration"
value={expiration}
/>
<Attribute name="Created" value={createdAt} />
<Attribute name="Expiration" value={expiration} />
<p className="mb-1 mt-4">
To use this key, run the following command on your device:
</p>
<Code className="text-sm">
tailscale up --login-server {server} --authkey {authKey.key}
tailscale up --login-server={url} --authkey {authKey.key}
</Code>
<div suppressHydrationWarning 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.');
}
@@ -0,0 +1,64 @@
import { useMemo, useState } from 'react';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface AddDomainProps {
domains: string[];
isDisabled?: boolean;
}
export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
const [domain, setDomain] = useState('');
const isInvalid = useMemo(() => {
if (!domain || domain.trim().length === 0) {
// Empty domain is invalid, but no error shown
return false;
}
if (domains.includes(domain.trim())) {
return true;
}
try {
// Check if domain is a valid FQDN
const url = new URL(`http://${domain.trim()}`);
return url.hostname !== domain.trim();
} catch (e) {
// If URL constructor fails, it's not a valid domain
return true;
}
}, [domain, domains]);
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Add domain</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add domain</Dialog.Title>
<Dialog.Text className="mb-4">
Add this domain to a list of allowed email domains that can
authenticate with Headscale via OIDC.
</Dialog.Text>
<input type="hidden" name="action_id" value="add_domain" />
<Input
isRequired
label="Domain"
description={
domain.trim().length > 0
? `Matches users with <user>@${domain.trim()}`
: 'Enter a domain to match users with their email addresses.'
}
placeholder="example.com"
name="domain"
onChange={setDomain}
isInvalid={domain.trim().length === 0 || isInvalid}
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
The domain you entered is invalid or already exists in the list.
</p>
)}
</Dialog.Panel>
</Dialog>
);
}
@@ -0,0 +1,51 @@
import { useMemo, useState } from 'react';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface AddGroupProps {
groups: string[];
isDisabled?: boolean;
}
export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
const [group, setGroup] = useState('');
const isInvalid = useMemo(() => {
if (!group || group.trim().length === 0) {
// Empty group is invalid, but no error shown
return false;
}
if (groups.includes(group.trim())) {
return true;
}
}, [group, groups]);
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Add group</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add group</Dialog.Title>
<Dialog.Text className="mb-4">
Add this group to a list of allowed groups that can authenticate with
Headscale via OIDC.
</Dialog.Text>
<input type="hidden" name="action_id" value="add_group" />
<Input
isRequired
label="Group"
description="The group to allow for OIDC authentication."
placeholder="admin"
name="group"
onChange={setGroup}
isInvalid={group.trim().length === 0 || isInvalid}
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
The group you entered already exists in the list of allowed groups.
</p>
)}
</Dialog.Panel>
</Dialog>
);
}
@@ -0,0 +1,51 @@
import { useMemo, useState } from 'react';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
interface AddUserProps {
users: string[];
isDisabled?: boolean;
}
export default function AddUser({ users, isDisabled }: AddUserProps) {
const [user, setUser] = useState('');
const isInvalid = useMemo(() => {
if (!user || user.trim().length === 0) {
// Empty user is invalid, but no error shown
return false;
}
if (users.includes(user.trim())) {
return true;
}
}, [user, users]);
return (
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Add user</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Add user</Dialog.Title>
<Dialog.Text className="mb-4">
Add this user to a list of allowed users that can authenticate with
Headscale via OIDC.
</Dialog.Text>
<input type="hidden" name="action_id" value="add_user" />
<Input
isRequired
label="User"
description="The user to allow for OIDC authentication."
placeholder="john_doe"
name="user"
onChange={setUser}
isInvalid={user.trim().length === 0 || isInvalid}
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
The user you entered already exists in the list of allowed users.
</p>
)}
</Dialog.Panel>
</Dialog>
);
}
@@ -0,0 +1,118 @@
import {
ActionFunctionArgs,
LoaderFunctionArgs,
Link as RemixLink,
data,
useLoaderData,
} from 'react-router';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { restrictionAction } from './actions';
import AddDomain from './dialogs/add-domain';
import AddGroup from './dialogs/add-group';
import AddUser from './dialogs/add-user';
import RestrictionTable from './table';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) {
throw data('You do not have permission to view IAM settings.', {
status: 403,
});
}
if (!context.hs.c?.oidc) {
throw data('OIDC is not configured on this Headscale instance.', {
status: 501,
});
}
return {
access: await context.sessions.check(request, Capabilities.configure_iam),
writable: context.hs.writable(),
settings: {
domains: [...new Set(context.hs.c.oidc.allowed_domains)],
groups: [...new Set(context.hs.c.oidc.allowed_groups)],
users: [...new Set(context.hs.c.oidc.allowed_users)],
},
};
}
export async function action(request: ActionFunctionArgs) {
return restrictionAction(request);
}
export default function Page() {
const { access, writable, settings } = useLoaderData<typeof loader>();
const isDisabled = writable ? !access : true;
return (
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-2/3">
<p className="mb-4 text-md">
<RemixLink to="/settings" className="font-medium">
Settings
</RemixLink>
<span className="mx-2">/</span> Authentication Restrictions
</p>
{!access ? (
<Notice
title="Authentication permissions restricted"
variant="warning"
>
You do not have the necessary permissions to edit the Authentication
Restrictions settings. Please contact your administrator to request
access or to make changes to these settings.
</Notice>
) : !writable ? (
<Notice title="Configuration Locked" variant="error">
The Headscale configuration file is not editable through the web
interface. Please ensure that you have correctly given Headplane
write access to the file.
</Notice>
) : undefined}
<h1 className="text-2xl font-medium mb-2 mt-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
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
name="Headscale OIDC documentation"
>
Learn More
</Link>
</p>
</div>
<RestrictionTable
type="domain"
values={settings.domains}
isDisabled={isDisabled}
>
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable
type="group"
values={settings.groups}
isDisabled={isDisabled}
>
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable
type="user"
values={settings.users}
isDisabled={isDisabled}
>
<AddUser users={settings.users} isDisabled={isDisabled} />
</RestrictionTable>
</div>
);
}
@@ -0,0 +1,85 @@
import { GlobeLock, Group, User2 } from 'lucide-react';
import React from 'react';
import { Form } from 'react-router';
import Button from '~/components/Button';
import TableList from '~/components/TableList';
import cn from '~/utils/cn';
interface RestrictionProps {
children: React.ReactNode;
type: 'domain' | 'group' | 'user';
values: string[];
isDisabled?: boolean;
}
export default function RestrictionTable({
children,
type,
values,
isDisabled,
}: RestrictionProps) {
return (
<div className="w-2/3">
<h2 className="text-2xl font-medium mt-8">
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
</h2>
<TableList className="my-4">
{values.length > 0 ? (
values.map((value) => (
<TableList.Item key={`${type}-${value}`}>
{type === 'domain' ? (
<p>
<span className="text-headplane-600 dark:text-headplane-300">
{'<user>'}
</span>
<span className="font-bold">@</span>
<span>{value}</span>
</p>
) : (
<p>{value}</p>
)}
<Form method="POST">
<input
type="hidden"
name="action_id"
value={`remove_${type}`}
/>
<input type="hidden" name={type} value={value} />
<Button
isDisabled={isDisabled}
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
>
Remove
</Button>
</Form>
</TableList.Item>
))
) : (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
{iconForType(type)}
<p className="font-semibold">
All {type}s are permitted to authenticate.
</p>
</TableList.Item>
)}
</TableList>
{children}
</div>
);
}
function iconForType(type: 'domain' | 'group' | 'user') {
if (type === 'domain') {
return <GlobeLock />;
}
if (type === 'group') {
return <Group />;
}
return <User2 />;
}
+281
View File
@@ -0,0 +1,281 @@
/** biome-ignore-all lint/correctness/noNestedComponentDefinitions: Wtf? */
import { faker } from '@faker-js/faker';
import { eq } from 'drizzle-orm';
import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
ActionFunctionArgs,
data,
LoaderFunctionArgs,
ShouldRevalidateFunction,
useLoaderData,
useSubmit,
} from 'react-router';
import wasm from '~/hp_ssh.wasm?url';
import { LoadContext } from '~/server';
import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
import { Machine, PreAuthKey, User } from '~/types';
import { useLiveData } from '~/utils/live-data';
import '~/wasm_exec';
import UserPrompt from './user-prompt';
import XTerm from './xterm.client';
export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
};
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.agents?.agentID()) {
throw data(
'WebSSH is only available with the Headplane agent integration',
400,
);
}
const session = await context.sessions.auth(request);
if (session.user.subject === 'unknown-non-oauth') {
throw data('Only OAuth users are allowed to use WebSSH', 403);
}
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.api_key,
);
// MARK: This assumes that a user has authenticated with Headscale first
// Since the only way to enforce permissions via ACLs is to generate a
// pre-authkey which REQUIRES a user ID, meaning the user has to have
// authenticated with Headscale first.
const lookup = users.find((u) => {
const subject = u.providerId?.split('/').pop();
if (!subject) {
return false;
}
return subject === session.user.subject;
});
if (!lookup) {
throw data(
`User with subject ${session.user.subject} not found within Headscale`,
404,
);
}
const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.api_key,
{
user: lookup.id,
reusable: false,
ephemeral: true,
expiration: new Date(Date.now() + 60 * 1000).toISOString(), // 1 minute
},
);
// TODO: Enable config to enforce generate_authkeys capability
// For now, any user is capable of WebSSH connections
// const check = await context.sessions.check(
// request,
// Capabilities.generate_authkeys,
// );
const qp = new URL(request.url).searchParams;
const username = qp.get('username') || undefined;
const hostname = qp.get('hostname') || undefined;
if (!hostname) {
throw data('Missing required parameter: hostname', 400);
}
if (!username) {
return {
ipnDetails: undefined,
sshDetails: {
username,
hostname,
},
};
}
// We're making a request to <url>/key?v=116 to check the CORS headers
const u = context.config.headscale.public_url ?? context.config.headscale.url;
// const res = await fetch(`${u}/key?v=116`, {
// method: 'GET',
// });
// const corsOrigin = res.headers.get('Access-Control-Allow-Origin');
// const corsMethods = res.headers.get('Access-Control-Allow-Methods');
// const corsHeaders = res.headers.get('Access-Control-Allow-Headers');
// console.log(corsOrigin, corsMethods, corsHeaders);
// if (!corsOrigin || !corsMethods || !corsHeaders) {
// throw data(
// 'Headscale server does not have the required CORS headers for WebSSH',
// 500,
// );
// }
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.api_key,
);
// node.name is the hostname, given_name is the set name
const lookupNode = nodes.find((n) => n.name === hostname);
if (!lookupNode) {
throw data(`Node with hostname ${hostname} not found`, 404);
}
// Last thing is keeping track of the ephemeral node in the database
// because Headscale doesn't automatically delete ephemeral nodes???
const [_ephemeralNode] = await context.db
.insert(ephemeralNodes)
.values({
auth_key: preAuthKey.key,
} satisfies EphemeralNodeInsert)
.returning();
return {
ipnDetails: {
PreAuthKey: preAuthKey.key,
Hostname: generateHostname(username),
ControlURL: u,
},
sshDetails: {
username,
hostname,
},
};
}
function generateHostname(username: string) {
const adjective = faker.word.adjective({
length: {
min: 3,
max: 6,
},
});
const noun = faker.word.noun({
length: {
min: 3,
max: 6,
},
});
return `ssh-${adjective}-${noun}-${username}`;
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const _session = await context.sessions.auth(request);
if (!context.agents?.agentID()) {
throw data(
'WebSSH is only available with the Headplane agent integration',
400,
);
}
const form = await request.formData();
const nodeKey = form.get('node_key');
const authKey = form.get('auth_key');
if (nodeKey === null || typeof nodeKey !== 'string') {
throw data('Missing node_key', 400);
}
if (authKey === null || typeof authKey !== 'string') {
throw data('Missing auth_key', 400);
}
await context.db
.update(ephemeralNodes)
.set({
node_key: nodeKey,
})
.where(eq(ephemeralNodes.auth_key, authKey));
}
export default function Page() {
const submit = useSubmit();
const { pause } = useLiveData();
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
const [nodeKey, setNodeKey] = useState<string | null>(null);
const { ipnDetails, sshDetails } = useLoaderData<typeof loader>();
useEffect(() => {
if (!ipnDetails) {
return;
}
pause();
const go = new Go(); // Go is defined by wasm_exec.js
WebAssembly.instantiateStreaming(fetch(wasm), go.importObject).then(
(value) => {
go.run(value.instance);
const handle = TsWasmNet(ipnDetails, {
NotifyState: (state) => {
console.log('State changed:', state);
if (state === 'Running') {
setIpn(handle);
}
},
NotifyNetMap: (netmap) => {
// Only set NodeKey if it is not already set and then
// also dispatch that to the backend to track the
// ephemeral node.
//
// We open an SSE connection to the backend
// so that when the connection is closed,
// the backend can delete the ephemeral node.
if (nodeKey === null) {
setNodeKey(netmap.NodeKey);
submit(
{
node_key: netmap.NodeKey,
auth_key: ipnDetails.PreAuthKey,
},
{ method: 'POST' },
);
}
},
NotifyBrowseToURL: (url) => {
console.log('Browse to URL:', url);
},
NotifyPanicRecover: (message) => {
console.error('Panic recover:', message);
},
});
handle.Start();
},
);
}, []);
if (!sshDetails.username) {
return <UserPrompt hostname={sshDetails.hostname} />;
}
return (
<div className="w-screen h-screen bg-headplane-900">
{ipn === null ? (
<div className="mx-auto h-screen flex items-center justify-center">
<Loader2 className="animate-spin size-10 text-headplane-50" />
</div>
) : (
<div className="flex flex-col h-screen">
<XTerm
hostname={sshDetails.hostname}
ipn={ipn}
username={sshDetails.username}
/>
</div>
)}
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
declare function TsWasmNet(
options: TsWasmNetOptions,
callbacks: TsWasmNetCallbacks,
): TsWasmNet;
interface TsWasmNetOptions {
ControlURL: string;
PreAuthKey: string;
Hostname: string;
}
interface TsWasmNetCallbacks {
NotifyState: (state: IPNState) => void;
NotifyNetMap: (netmap: TsWasmNetMap) => void;
NotifyBrowseToURL: (url: string) => void;
NotifyPanicRecover: (err: string) => void;
}
interface TsWasmNetMap {
NodeKey: string;
}
interface TsWasmNet {
Start: () => void;
OpenSSH: (
hostname: string,
username: string,
options: XtermConfig,
) => SSHSession;
}
type IPNState =
| 'NoState'
| 'InUseOtherUser'
| 'NeedsLogin'
| 'NeedsMachineAuth'
| 'Stopped'
| 'Starting'
| 'Running';
interface XtermConfig {
rows: number;
cols: number;
timeout?: number;
onStdout: (data: Uint8Array) => void;
onStderr: (data: Uint8Array) => void;
onStdin: (func: (input: Uint8Array) => void) => void;
onConnect: () => void;
onDisconnect: () => void;
}
interface SSHSession {
Close(): boolean;
Resize(rows: number, cols: number): boolean;
}
+47
View File
@@ -0,0 +1,47 @@
import { useState } from 'react';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
interface UserPromptProps {
hostname: string;
}
export default function UserPrompt({ hostname }: UserPromptProps) {
const [username, setUsername] = useState('');
return (
<div className="flex items-center justify-center h-screen">
<Card>
<Card.Title>Enter Username</Card.Title>
<Card.Text className="mb-4">
Enter the username you want to use to connect to{' '}
<Code>{hostname}</Code>
{'. '}
WebSSH follows the Headscale ACLs, so only permitted usernames will be
able to connect.
</Card.Text>
<Input
labelHidden
type="text"
label="Username"
placeholder="Username"
className="mb-2"
onChange={setUsername}
/>
<Button
variant="heavy"
className="w-full"
onPress={() => {
// We can't use the navigate hook here as we need to do a
// full page reload to ensure the SSH connection is established
window.location.href = `${__PREFIX__}/ssh?hostname=${hostname}&username=${username}`;
}}
>
Connect
</Button>
</Card>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
declare class Go {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
argv?: string[];
env?: Record<string, string>;
exit?: (code: number) => void;
}

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