Compare commits

...

167 Commits

Author SHA1 Message Date
Aarnav Tale 28ad1d439f Merge branch 'main' into next 2025-10-30 23:52:35 -04:00
Aarnav Tale 135d2940f1 chore: fix hash 2025-10-30 23:38:54 -04:00
Aarnav Tale 497a782f17 docs: fix link typo for configuration 2025-10-30 17:30:13 -04:00
Aarnav Tale 6d5e44f585 fix: pnpm lifecycle scripts broke nix 2025-10-28 09:58:53 -04:00
Aarnav Tale 94e5cb95c6 Merge pull request #342 from tale/update_flake_lock_action 2025-10-27 22:00:56 -04:00
Aarnav Tale 800d73248d Merge pull request #349 from tituspijean/patch-1 2025-10-27 22:00:14 -04:00
tituspijean 66b6dea068 fix: write_ahead_log in sqlite config schema 2025-10-27 21:57:52 +01:00
github-actions[bot] 489b12460f flake.lock: Update
Flake lock file updates:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Maybe actually fix builds

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

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

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

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

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/076e8c6678d8c54204abcb4b1b14c366835a58bb?narHash=sha256-1ovgdmuDYVo5OUC5NzdF%2BV4zx2uT8RtsgZahxidBTyw%3D' (2025-06-20)
  → 'github:nixos/nixpkgs/80d50fc87924c2a0d346372d242c27973cf8cdbf?narHash=sha256-3u6rEbIX9CN/5A5/mc3u0wIO1geZ0EhjvPBXmRDHqWM%3D' (2025-06-27)
2025-06-30 12:17:22 -04:00
Aarnav Tale ce4617d0e1 chore: walk back on nonroot, its too much of a breaking change 2025-06-24 12:23:05 -04:00
Aarnav Tale 6d99aca058 feat: add matrix build for release 2025-06-24 12:06:23 -04:00
Aarnav Tale d809eea564 feat: finalize split debug and distroless container (closes #255) 2025-06-24 11:28:55 -04:00
Aarnav Tale eacde3deb8 chore: attestation is sha256 based, we dont need tags 2025-06-24 11:15:28 -04:00
Aarnav Tale fc7bedc8b1 feat: append -next to pre-release versions in pnpm build 2025-06-24 11:04:24 -04:00
Aarnav Tale ecc547601f fix: pass in github token for mise ratelimits 2025-06-24 11:01:16 -04:00
Aarnav Tale eed12d348e feat: build a debug-shell and distroless container 2025-06-24 10:56:43 -04:00
Aarnav Tale 5a254b746b docs: enforce node lts 22.16 which has sqlite support unflagged 2025-06-24 10:39:51 -04:00
Aarnav Tale 2cc4339499 chore: update changelog 2025-06-23 00:35:50 -04:00
Aarnav Tale 84c820efb1 chore: merge remote-tracking branch 'origin' into next 2025-06-23 00:35:04 -04:00
Gabe Cook 779af42601 fix: username never falling back to email 2025-06-23 00:33:29 -04:00
Aarnav Tale f990670e86 Merge remote-tracking branch 'origin/main' into next 2025-06-22 17:59:03 -04:00
Peder 'hayer' Husom 0344f17887 Proc mode: try to guess PID based on PPID when multiple found 2025-06-22 17:57:52 -04:00
Aarnav Tale f28bfd5f86 fix: add attestation perms to gha 2025-06-22 17:36:22 -04:00
Aarnav Tale 8f1b577b5e fix: allow id-token write in gh actions 2025-06-22 17:32:39 -04:00
github-actions[bot] 6ea9fe169d chore: update flake.lock (#260)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-22 17:28:45 -04:00
Aarnav Tale 9a81b76398 feat: add provenance attestations 2025-06-22 17:28:19 -04:00
Aarnav Tale 144a8b87cd feat: cache docker build cache using gha 2025-06-22 17:17:56 -04:00
Aarnav Tale 2bf088b4a9 feat: speed up docker build 2025-06-22 16:59:21 -04:00
Aarnav Tale 87b8d64bcc feat: reintroduce missing local dns override (fixes #236) 2025-06-21 13:54:33 -04:00
Aarnav Tale 8819af270d fix: properly handle removing the last split dns record
Previously it would set the value to an empty array, breaking DNS resolution for that split completely.
The correct behavior was to remove the key altogether! This fixes #231
2025-06-21 13:08:31 -04:00
Aarnav Tale 1150d1616e fix: support user input when not specified for ssh 2025-06-20 20:09:28 -04:00
Aarnav Tale dd287c0444 fix: menu should work even with ssh target 2025-06-20 19:55:02 -04:00
Aarnav Tale cf55621b57 docs: warn about permission change 2025-06-20 19:46:20 -04:00
Aarnav Tale 73f0a0d273 fix: use distroless and fix build 2025-06-20 11:32:27 -04:00
Aarnav Tale 71f130ede5 feat: switch to libsql since its esm friendly 2025-06-20 10:36:29 -04:00
Aarnav Tale c0af2aa9c8 chore: update changelog 2025-06-20 00:20:47 -04:00
Aarnav Tale b18147fa82 feat: cleanup removal of old ssh plexer and logic
we also have added the necessary logic to auto prune ephemeral nodes because
headscale doesn't seem to automatically remove them. this change made use of a database
which is now stored in the persistent headplane directory.
2025-06-20 00:14:00 -04:00
Aarnav Tale bf1d75a27a fix: resize cols and rows, not the other way around *sigh* 2025-06-18 13:37:47 -04:00
Aarnav Tale 1e86b0e95b feat: add ssh buttons in the ui using hostinfo checks 2025-06-18 11:56:27 -04:00
Aarnav Tale b34a3f0ca1 chore: sigh update pnpm deps hash 2025-06-16 11:45:45 -04:00
Aarnav Tale 799d7b5728 chore: update go deps nix hash 2025-06-16 11:45:26 -04:00
Aarnav Tale 4961e83a9c fix: cleanup agent and add to ci 2025-06-16 11:45:25 -04:00
Aarnav Tale ab7587e7a9 chore: cache go build deps 2025-06-16 11:45:25 -04:00
Aarnav Tale 6d15c411f1 chore: use mise for ci 2025-06-16 11:45:25 -04:00
Aarnav Tale 7f376c3f70 fix: better ssh resilience and customizable timeout for future backoff 2025-06-16 11:45:24 -04:00
Aarnav Tale 6a0e097412 chore: fix tiny nits 2025-06-16 11:45:24 -04:00
Aarnav Tale 721439868f style: format go code on precommit 2025-06-16 11:45:24 -04:00
Aarnav Tale f4af5b920d feat: cleanup and streamline webssh capability 2025-06-16 11:45:23 -04:00
Aarnav Tale 0f9bf73b82 chore: reorganize go code 2025-06-16 11:45:23 -04:00
Aarnav Tale 7a6ad8d2d5 feat: add horrible barely-working go ipn wasm code 2025-06-16 11:45:23 -04:00
Aarnav Tale f9751f5ab2 chore: add tooling for go wasm 2025-06-16 11:45:23 -04:00
Aarnav Tale 46f1576b8f chore: update nix go hash 2025-06-16 11:45:21 -04:00
Aarnav Tale ccde3513dc feat: support resizing and other xterm.js addons 2025-06-16 11:45:00 -04:00
Aarnav Tale a0a80852eb fix: connect to stdout fd after starting an ssh shell
this avoids a race condition that would happen if the goroutine tried to read a
file descriptor that wasn't open properly and would lose frame delivery
2025-06-16 11:45:00 -04:00
Aarnav Tale cb32637938 feat: add xterm frontend ui 2025-06-16 11:44:59 -04:00
Aarnav Tale 55eacb59e9 feat: expand frame type to support stdout/stderr chan 2025-06-16 11:44:59 -04:00
Aarnav Tale 7dfcbef774 feat: initial webssh tooling 2025-06-16 11:44:59 -04:00
github-actions[bot] f6d8ad25e1 chore: update flake.lock (#247)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-16 11:41:50 -04:00
Guillaume e36193a8c0 feat: add logo and favicon (#238) 2025-06-09 10:30:03 -04:00
Antônio Lago 0c87f3e6ec docs: fix acl mode key in docs (#241) 2025-06-09 10:29:51 -04:00
github-actions[bot] 090f5c1ba9 chore: update flake.lock (#243)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-09 10:27:37 -04:00
Sizhe Zhao 9b9d5432c6 fix: update nix deps hash (#244) 2025-06-09 10:26:29 -04:00
github-actions[bot] 2a26a74b65 chore: update flake.lock (#235)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-06-05 23:07:57 -04:00
Gabe Cook a298686d6b fix: fallback to user email when username is unset (#237) 2025-06-05 23:06:38 -04:00
Aarnav Tale 2f316176c8 fix: use /proc/pid/comm to check commands 2025-05-29 12:05:30 -04:00
Aarnav Tale 59525b7b63 feat: upgrade to kubernetes client 1.x 2025-05-29 11:55:30 -04:00
Aarnav Tale b7009e387b feat: upgrade to tailwindcss v4 2025-05-29 11:16:47 -04:00
Aarnav Tale e173376319 chore: update deps 2025-05-29 11:16:47 -04:00
github-actions[bot] 59d7f15694 chore: update flake.lock (#216)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2025-05-25 11:05:24 -04:00
197 changed files with 12463 additions and 6533 deletions
+8 -4
View File
@@ -1,7 +1,11 @@
node_modules
.dockerignore
Dockerfile
/.cache /.cache
/.github
/.react-router
/node_modules
/build /build
/docs
/nix
.dockerignore
.env .env
Dockerfile
-1
View File
@@ -1 +0,0 @@
use_flake
+3
View File
@@ -0,0 +1,3 @@
* @tale
/nix @tale @StealthBadger747
+14 -14
View File
@@ -28,20 +28,15 @@ jobs:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install node.js - name: Setup Mise
uses: actions/setup-node@v4 uses: jdx/mise-action@v2
with:
node-version: 22
- uses: pnpm/action-setup@v4 - name: Set caching paths
name: Install pnpm
with:
run_install: false
- name: Get pnpm store directory
shell: bash shell: bash
run: | run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV 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 - uses: actions/cache@v4
name: Setup pnpm cache name: Setup pnpm cache
@@ -51,11 +46,16 @@ jobs:
restore-keys: | restore-keys: |
${{ runner.os }}-pnpm-store- ${{ runner.os }}-pnpm-store-
- name: Install dependencies - name: Setup Go cache
run: pnpm install uses: actions/cache@v4
with:
path: |
${{ env.GO_CACHE }}
${{ env.GO_MODCACHE }}
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod', '**/go.sum') }}
- name: Build - name: CI pipeline
run: pnpm build run: ./build.sh --skip-pnpm-prune
nix: nix:
name: nix name: nix
+29 -3
View File
@@ -12,6 +12,8 @@ permissions:
actions: write # Allow canceling in-progress runs actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository contents: read # Read access to the repository
packages: write # Write access to the container registry 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: jobs:
publish: publish:
@@ -19,6 +21,14 @@ jobs:
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') }} 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 name: Docker Pre-release
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: final
tag: 'next'
- target: debug-shell
tag: 'next-shell'
steps: steps:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -29,7 +39,7 @@ jobs:
with: with:
images: ghcr.io/${{ github.repository }} images: ghcr.io/${{ github.repository }}
tags: | tags: |
type=raw,value=next type=raw,value=${{ matrix.tag }}
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -44,12 +54,28 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image - name: Build and publish ghcr.io/${{ github.repository }}:${{ matrix.tag }}
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
id: push
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
target: ${{ matrix.target }}
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64 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
+28 -2
View File
@@ -12,11 +12,21 @@ permissions:
actions: write # Allow canceling in-progress runs actions: write # Allow canceling in-progress runs
contents: read # Read access to the repository contents: read # Read access to the repository
packages: write # Write access to the container registry 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: jobs:
docker: docker:
name: Docker Release name: Docker Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
strategy:
matrix:
include:
- target: final
tag_suffix: ''
- target: debug-shell
tag_suffix: '-shell'
steps: steps:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -28,6 +38,9 @@ jobs:
images: ghcr.io/${{ github.repository }} images: ghcr.io/${{ github.repository }}
tags: | tags: |
type=semver,pattern={{version}} type=semver,pattern={{version}}
flavor: |
latest=auto
suffix=${{ matrix.tag_suffix }},onlatest=true
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -42,12 +55,25 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image - name: Build and push ${{ fromJSON(steps.meta.outputs.json).tags[0] }}
uses: docker/build-push-action@v5 uses: docker/build-push-action@v6
id: push
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
target: ${{ matrix.target }}
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64, linux/arm64 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
+9
View File
@@ -4,3 +4,12 @@ node_modules
/build /build
/test /test
.env .env
/app/hp_ssh.wasm
/app/wasm_exec.js
/public/hp_ssh.wasm
/public/wasm_exec.js
/docs/.vitepress/dist/
/docs/.vitepress/cache/
/.direnv
-2
View File
@@ -1,2 +0,0 @@
pnpm 10.4.0
node 22
-17
View File
@@ -1,17 +0,0 @@
{
"formatter": {
"language_server": {
"name": "biome"
}
},
"code_actions_on_format": {
"source.fixAll.biome": true,
"source.organizeImports.biome": true
},
"languages": {
"YAML": {
"tab_size": 2,
"hard_tabs": false
}
}
}
+147 -37
View File
@@ -1,4 +1,42 @@
### 0.6.0 (May 25, 2025) # 0.6.2 (Next)
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
- Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)).
- Implemented the ability to customize the build with a custom script (see `./build.sh --help` for more information).
---
# 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. - 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)). - 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. - Older versions of Headscale will not work with Headplane.
@@ -23,15 +61,21 @@
- Tighten `proc` integration logic by checking for the `headscale serve` command (via #[195](https://github.com/tale/headplane/pull/195)). - 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)). - Strip newlines in the OIDC `client_secret_path` file if provided (closes [#199](https://github.com/tale/headplane/issues/199)).
### 0.5.10 (April 4, 2025) ---
# 0.5.10 (April 4, 2025)
- Fix an issue where other preferences to skip onboarding affected every user. - Fix an issue where other preferences to skip onboarding affected every user.
### 0.5.9 (April 3, 2025) ---
# 0.5.9 (April 3, 2025)
- Filter out empty users from the pre-auth keys page which could possibly cause a crash with unmigrated users. - 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. - OIDC users cannot be renamed, so that functionality has been disabled in the menu options.
- Suppress hydration errors for any fields with a date in it. - Suppress hydration errors for any fields with a date in it.
### 0.5.8 (April 3, 2025) ---
# 0.5.8 (April 3, 2025)
- You can now skip the onboarding page if desired. - You can now skip the onboarding page if desired.
- Added the UI to change user roles in the dashboard. - Added the UI to change user roles in the dashboard.
- Fixed an issue where integrations would throw instead of loading properly. - Fixed an issue where integrations would throw instead of loading properly.
@@ -39,10 +83,14 @@
- Automatically create `/var/lib/headplane` in the Docker container (fixes [#166](https://github.com/tale/headplane/issues/166)) - Automatically create `/var/lib/headplane` in the Docker container (fixes [#166](https://github.com/tale/headplane/issues/166))
- OIDC logout with `disable_api_key_login` set to true will not automatically login again (fixes [#149](https://github.com/tale/headplane/issues/149)) - OIDC logout with `disable_api_key_login` set to true will not automatically login again (fixes [#149](https://github.com/tale/headplane/issues/149))
### 0.5.7 (April 2, 2025) ---
# 0.5.7 (April 2, 2025)
- Hotfix an issue where assets aren't served under `/admin` or the prefix. - Hotfix an issue where assets aren't served under `/admin` or the prefix.
### 0.5.6 (April 2, 2025) ---
# 0.5.6 (April 2, 2025)
### IMPORTANT ### IMPORTANT
> **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously *ANY* accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access. > **PLEASE** update to this ASAP if you were using Google OIDC. This is because previously *ANY* accounts have admin access to your Tailnet if they discover the URL that Headplane is being hosted on. This new change enforces that new logins by default are not given any permissions. You will need to re-login to Headplane to generate an owner account and prevent unauthorized access.
@@ -62,10 +110,14 @@ If there are no users, the first user will be given an `owner` role which cannot
- Unknown values in the Headplane config no longer cause a crash. - Unknown values in the Headplane config no longer cause a crash.
- Fixed an issue that caused copied commands to have a random space (fixes [#161](https://github.com/tale/headplane/issues/161)) - Fixed an issue that caused copied commands to have a random space (fixes [#161](https://github.com/tale/headplane/issues/161))
### 0.5.5 (March 18, 2025) ---
# 0.5.5 (March 18, 2025)
- Hotfix an issue that caused Headplane to crash if no agents are available - Hotfix an issue that caused Headplane to crash if no agents are available
### 0.5.4 (March 18, 2025) ---
# 0.5.4 (March 18, 2025)
- Fixed a typo in the Kubernetes documentation - Fixed a typo in the Kubernetes documentation
- Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129)) - Handle split and global DNS records not being set in the Headscale config (via [#129](https://github.com/tale/headplane/pull/129))
- Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131)) - Stop checking for the `mkey:` prefix on machine registration (via [#131](https://github.com/tale/headplane/pull/131))
@@ -79,20 +131,28 @@ If there are no users, the first user will be given an `owner` role which cannot
- You can now pass in an OIDC client secret through `oidc.client_secret_path` in the config (fixes [#126](https://github.com/tale/headplane/issues/126)) - You can now pass in an OIDC client secret through `oidc.client_secret_path` in the config (fixes [#126](https://github.com/tale/headplane/issues/126))
- Correctly handle differently localized number inputs (fixes [#125](https://github.com/tale/headplane/issues/125)) - Correctly handle differently localized number inputs (fixes [#125](https://github.com/tale/headplane/issues/125))
### 0.5.3 (March 1, 2025) ---
# 0.5.3 (March 1, 2025)
- Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111)) - Fixed an issue where Headplane expected the incorrect config value for OIDC scope (fixes [#111](https://github.com/tale/headplane/issues/111))
- Added an ARIA indicator for when an input is required and fixed the confirm buttons (fixed [#116](https://github.com/tale/headplane/issues/116)) - Added an ARIA indicator for when an input is required and fixed the confirm buttons (fixed [#116](https://github.com/tale/headplane/issues/116))
- Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112)) - Fixed a typo in the docs that defaulted to `/var/run/docker.dock` for the Docker socket (via [#112](https://github.com/tale/headplane/pull/112))
### 0.5.2 (February 28, 2025) ---
# 0.5.2 (February 28, 2025)
- Hotfixed an issue where the server bundle got reloaded on each request - Hotfixed an issue where the server bundle got reloaded on each request
### 0.5.1 (February 28, 2025) ---
# 0.5.1 (February 28, 2025)
- Fixed an issue that caused the entire server to crash on start - Fixed an issue that caused the entire server to crash on start
- Fixed the published semver tags from Docker - Fixed the published semver tags from Docker
- Fixed the Kubernetes integration not reading the config - Fixed the Kubernetes integration not reading the config
### 0.5 (February 27, 2025) ---
# 0.5 (February 27, 2025)
> This release is a major overhaul and contains a significant breaking change. > This release is a major overhaul and contains a significant breaking change.
> We now use a config file for all settings instead of environment variables. > We now use a config file for all settings instead of environment variables.
> Please see [config.example.yaml](/config.example.yaml) for the new format. > Please see [config.example.yaml](/config.example.yaml) for the new format.
@@ -107,10 +167,14 @@ If there are no users, the first user will be given an `owner` role which cannot
- Settings that were previously shared like `public_url` or `oidc` are now separate within Headplane/Headscale. This is a rather large breaking change but fixes cases where a user may choose to utilize Headscale OIDC for Tailscale but not for the Headplane UI. - Settings that were previously shared like `public_url` or `oidc` are now separate within Headplane/Headscale. This is a rather large breaking change but fixes cases where a user may choose to utilize Headscale OIDC for Tailscale but not for the Headplane UI.
- Deprecate the `latest` tag in Docker for explicit versioning and `edge` for nightly builds. - Deprecate the `latest` tag in Docker for explicit versioning and `edge` for nightly builds.
### 0.4.1 (January 18, 2025) ---
# 0.4.1 (January 18, 2025)
- Fixed an urgent issue where the OIDC redirect URI would mismatch. - Fixed an urgent issue where the OIDC redirect URI would mismatch.
### 0.4.0 (January 18, 2025) ---
# 0.4.0 (January 18, 2025)
- Switched from Remix.run to React-Router - Switched from Remix.run to React-Router
- Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66)) - Fixed an issue where some config fields were marked as required even if they weren't (fixes [#66](https://github.com/tale/headplane/issues/66))
- Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68)) - Fixed an issue where the toasts would be obscured by the footer (fixes [#68](https://github.com/tale/headplane/issues/68))
@@ -123,29 +187,41 @@ If there are no users, the first user will be given an `owner` role which cannot
- Reusable Pre-Auth Keys no longer show expired when used (PR [#88](https://github.com/tale/headplane/pull/88)) - Reusable Pre-Auth Keys no longer show expired when used (PR [#88](https://github.com/tale/headplane/pull/88))
- Tweaked some CSS issues in the UI - Tweaked some CSS issues in the UI
### 0.3.9 (December 6, 2024) ---
# 0.3.9 (December 6, 2024)
- Fixed a race condition bug in the OIDC validation code - Fixed a race condition bug in the OIDC validation code
### 0.3.8 (December 6, 2024) ---
# 0.3.8 (December 6, 2024)
- Added a little HTML footer to show the login page and link to a donation page. - Added a little HTML footer to show the login page and link to a donation page.
- Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58)) - Allow creating pre-auth keys that expire past 90 days (fixes [#58](https://github.com/tale/headplane/issues/58))
- Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63)) - Validates OIDC config and ignores validation if specified via variables or Headscale config (fixes [#63](https://github.com/tale/headplane/issues/63))
### 0.3.7 (November 30, 2024) ---
# 0.3.7 (November 30, 2024)
- Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57)) - Allow customizing the OIDC token endpoint auth method via `OIDC_CLIENT_SECRET_METHOD` (fixes [#57](https://github.com/tale/headplane/issues/57))
- Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59)) - Added a `/healthz` endpoint for Kubernetes and other health checks (fixes [#59](https://github.com/tale/headplane/issues/59))
- Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60)) - Allow `HEADSCALE_PUBLIC_URL` to be set if `HEADSCALE_URL` points to a different internal address (fixes [#60](https://github.com/tale/headplane/issues/60))
- Fixed an issue where the copy machine registration command had a typo. - Fixed an issue where the copy machine registration command had a typo.
### 0.3.6 (November 20, 2024) ---
# 0.3.6 (November 20, 2024)
- Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53)) - Fixed an issue where select dropdowns would not scroll (fixes [#53](https://github.com/tale/headplane/issues/53))
- Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52)) - Added a button to copy the machine registration command to the clipboard (fixes [#52](https://github.com/tale/headplane/issues/52))
### 0.3.5 (November 8, 2024) ---
# 0.3.5 (November 8, 2024)
- Quickfix a bug where environment variables are ignored on the server. - Quickfix a bug where environment variables are ignored on the server.
- Remove a nagging error about missing cookie since that happens when signed out. - Remove a nagging error about missing cookie since that happens when signed out.
### 0.3.4 (November 7, 2024) ---
# 0.3.4 (November 7, 2024)
- Clicking on the machine name in the users page now takes you to the machine overview page. - Clicking on the machine name in the users page now takes you to the machine overview page.
- Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server. - Completely rebuilt the production server to work better outside of Docker and be lighter. More specifically, we've switched from the `@remix-run/serve` package to our own custom built server.
- Fixed a bunch of silly issues introduced by me not typechecking the codebase. - Fixed a bunch of silly issues introduced by me not typechecking the codebase.
@@ -155,70 +231,94 @@ If there are no users, the first user will be given an `owner` role which cannot
- Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36)) - Added full support for Exit Nodes in the UI and redesigned the machines page (fixes [#36](https://github.com/tale/headplane/issues/36))
- Added a basic check to see if the API keys passed via cookies are invalid. - Added a basic check to see if the API keys passed via cookies are invalid.
### 0.3.3 (October 28, 2024) ---
# 0.3.3 (October 28, 2024)
- Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable. - Added the ability to load a `.env` file from the PWD when `LOAD_ENV_FILE=true` is set as an environment variable.
- Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error - Fixed an issue where non-English languages could not create Pre-auth keys due to a localization error
- Improved ACL editor performance by switching back to CodeMirror 6 - Improved ACL editor performance by switching back to CodeMirror 6
- Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34)) - Fixed an issue where editing the ACL policy would cause it to revert on the UI (fixes [#34](https://github.com/tale/headplane/issues/34))
- Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler)) - Updated to the next stable beta of the React 19 Compiler ([See More](https://react.dev/learn/react-compiler))
### 0.3.2 (October 11, 2024) ---
# 0.3.2 (October 11, 2024)
- Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22)) - Implement the ability to create and expire pre-auth keys (fixes [#22](https://github.com/tale/headplane/issues/22))
- Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27)) - Fix machine registration not working as expected (fixes [#27](https://github.com/tale/headplane/issues/27))
- Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35)) - Removed more references to usernames in MagicDNS hostnames (fixes [#35](https://github.com/tale/headplane/issues/35))
- Handle `null` values on machine expiry when using a database like PostgreSQL. - Handle `null` values on machine expiry when using a database like PostgreSQL.
- Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL. - Use `X-Forwarded-Proto` and `Host` headers for building the OIDC callback URL.
### 0.3.1 (October 3, 2024) ---
# 0.3.1 (October 3, 2024)
- Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously. - Fixed the Docker integration to properly support custom socket paths. This regressed at some point previously.
- Allow you to register a machine using machine keys (`nodekey:...`) on the machines page. - Allow you to register a machine using machine keys (`nodekey:...`) on the machines page.
- Added the option for debug logs with the `DEBUG=true` environment variable. - Added the option for debug logs with the `DEBUG=true` environment variable.
### 0.3.0 (September 25, 2024) ---
# 0.3.0 (September 25, 2024)
- Bumped the minimum supported version of Headscale to 0.23. - Bumped the minimum supported version of Headscale to 0.23.
- Updated the UI to respect `dns.use_username_in_magic_dns`. - Updated the UI to respect `dns.use_username_in_magic_dns`.
### 0.2.4 (August 24, 2024) ---
# 0.2.4 (August 24, 2024)
- Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively. - Removed ACL management from the integration since Headscale 0.23-beta2 now supports it natively.
- Removed the `ACL_FILE` environment variable since it's no longer needed. - Removed the `ACL_FILE` environment variable since it's no longer needed.
- Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies. - Introduce a `COOKIE_SECURE=false` environment variable to disable HTTPS requirements for cookies.
- Fixed a bug where removing Split DNS configurations would crash the UI. - Fixed a bug where removing Split DNS configurations would crash the UI.
### 0.2.3 (August 23, 2024) ---
# 0.2.3 (August 23, 2024)
- Change the minimum required version of Headscale to 0.23-beta2 - Change the minimum required version of Headscale to 0.23-beta2
- Support the new API policy mode for Headscale 0.23-beta1 - Support the new API policy mode for Headscale 0.23-beta1
- Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29)) - Switch to the new DNS configuration in Headscale 0.23-beta2 (fixes [#29](https://github.com/tale/headplane/issues/29))
- If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24)) - If OIDC environment variables are defined, don't use configuration file values (fixes [#24](https://github.com/tale/headplane/issues/24))
### 0.2.2 (August 2, 2024) ---
# 0.2.2 (August 2, 2024)
- Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs. - Added a proper Kubernetes integration which utilizes `shareProcessNamespace` for PIDs.
- Added a new logger utility that shows categories, levels, and timestamps. - Added a new logger utility that shows categories, levels, and timestamps.
- Reimplemented the integration system to be more resilient and log more information. - Reimplemented the integration system to be more resilient and log more information.
- Fixed an issue where the /proc integration found `undefined` PIDs. - Fixed an issue where the /proc integration found `undefined` PIDs.
### 0.2.1 (July 7, 2024) ---
# 0.2.1 (July 7, 2024)
- Added the ability to manage custom DNS records on your Tailnet. - Added the ability to manage custom DNS records on your Tailnet.
- ACL tags for machines are now able to be changed via the machine menu. - ACL tags for machines are now able to be changed via the machine menu.
- Fixed a bug where the ACL editor did not show the diffs correctly. - Fixed a bug where the ACL editor did not show the diffs correctly.
- Fixed an issue that stopped the "Discard changes" button in the ACL editor from working. - Fixed an issue that stopped the "Discard changes" button in the ACL editor from working.
### 0.2.0 (June 23, 2024) ---
# 0.2.0 (June 23, 2024)
- Fix the dropdown options for machines not working on the machines page. - Fix the dropdown options for machines not working on the machines page.
- Add an option to change the machine owner in the dropdown (aside from the users page). - Add an option to change the machine owner in the dropdown (aside from the users page).
### 0.1.9 (June 2, 2024) ---
# 0.1.9 (June 2, 2024)
- Switch to Monaco editor with proper HuJSON and YAML syntax highlighting. - Switch to Monaco editor with proper HuJSON and YAML syntax highlighting.
- Utilize magic DNS hostnames for the machine overview page. - Utilize magic DNS hostnames for the machine overview page.
- Fixed the expiry issue once and for all. - Fixed the expiry issue once and for all.
- Add a nightly build with the `ghcr.io/tale/headplane:edge` tag - Add a nightly build with the `ghcr.io/tale/headplane:edge` tag
### 0.1.8 (June 2, 2024) ---
# 0.1.8 (June 2, 2024)
- Built basic functionality for the machine overview page (by machine ID). - Built basic functionality for the machine overview page (by machine ID).
- Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly. - Possibly fixed an issue where expiry disabled machines' timestamps weren't handled correctly.
- Prevent users from being deleted if they still have ownership of machines. - Prevent users from being deleted if they still have ownership of machines.
- Fixed some type issues where `Date` was being used instead of `string` for timestamps. - Fixed some type issues where `Date` was being used instead of `string` for timestamps.
### 0.1.7 (May 30, 2024) ---
# 0.1.7 (May 30, 2024)
- Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker. - Added support for the `HEADSCALE_INTEGRATION` variable to allow for advanced integration without Docker.
- Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes. - Fixed a bug where the `expiry` field on the Headscale configuration could cause crashes.
- Made the strict configuration loader more lenient to allow for more flexibility. - Made the strict configuration loader more lenient to allow for more flexibility.
@@ -226,19 +326,25 @@ If there are no users, the first user will be given an `owner` role which cannot
- Headplane's context now only loads once at start instead of being lazy-loaded. - Headplane's context now only loads once at start instead of being lazy-loaded.
- Improved logging and error propagation so that it's easier to debug issues. - Improved logging and error propagation so that it's easier to debug issues.
### 0.1.6 (May 22, 2024) ---
# 0.1.6 (May 22, 2024)
- Added experimental support for advanced integration without Docker. - Added experimental support for advanced integration without Docker.
- Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context. - Fixed a crash where the Docker integration tried to use `process.env.API_KEY` instead of context.
- Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow. - Fixed a crash where `ROOT_API_KEY` was not respected in the OIDC flow.
### 0.1.5 (May 20, 2024) ---
# 0.1.5 (May 20, 2024)
- Robust configuration handling with fallbacks based on the headscale source. - Robust configuration handling with fallbacks based on the headscale source.
- Support for `client_secret_path` on configuration file based OIDC. - Support for `client_secret_path` on configuration file based OIDC.
- `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work). - `DISABLE_API_KEY_LOGIN` now works as expected (non 'true' values work).
- `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works). - `API_KEY` is renamed to `ROOT_API_KEY` for better clarity (old variable still works).
- Fixed button responders not actually being invoked (should fix the ACL page). - Fixed button responders not actually being invoked (should fix the ACL page).
### 0.1.4 (May 15, 2024) ---
# 0.1.4 (May 15, 2024)
- Users can now be created, renamed, and deleted on the users page. - Users can now be created, renamed, and deleted on the users page.
- Machines can be dragged between users to change their ownership. - Machines can be dragged between users to change their ownership.
@@ -247,13 +353,17 @@ If there are no users, the first user will be given an `owner` role which cannot
- Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues). - Upgrade to React 19 beta to take advantage of the compiler (may revert if it causes issues).
- Upgrade other dependencies - Upgrade other dependencies
### 0.1.3 (May 4, 2024) ---
# 0.1.3 (May 4, 2024)
- Switched to a better icon set for the UI. - Switched to a better icon set for the UI.
- Support stable scrollbar gutter if supported by the browser. - Support stable scrollbar gutter if supported by the browser.
- Cleaned up the header which fixed a bug that could crash the entire application on fetch errors. - Cleaned up the header which fixed a bug that could crash the entire application on fetch errors.
### 0.1.2 (May 1, 2024) ---
# 0.1.2 (May 1, 2024)
- Added support for renaming, expiring, removing, and managing the routes of a machine. - Added support for renaming, expiring, removing, and managing the routes of a machine.
- Implemented an expiry check for machines which now reflect on the machine table. - Implemented an expiry check for machines which now reflect on the machine table.
+10
View File
@@ -0,0 +1,10 @@
https://localhost {
reverse_proxy headscale:8080
tls /certs/localhost.pem /certs/localhost-key.pem
header {
Access-Control-Allow-Origin *
Access-Control-Allow-Headers *
Access-Control-Allow-Methods GET, POST, OPTIONS
}
}
+57 -26
View File
@@ -1,35 +1,66 @@
FROM golang:1.24 AS agent-build FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
WORKDIR /app WORKDIR /run
COPY go.mod go.sum ./ COPY go.mod go.sum build.sh ./
RUN go mod download RUN go mod download
COPY agent/ ./agent COPY cmd/ ./cmd/
RUN CGO_ENABLED=0 GOOS=linux go build \ COPY internal/ ./internal/
-trimpath \
-ldflags "-s -w" \
-o /app/hp_agent ./agent/cmd/hp_agent
FROM node:22-alpine AS build ARG TARGETOS
WORKDIR /app ARG TARGETARCH
ARG IMAGE_TAG
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 IMAGE_TAG=$IMAGE_TAG \
./build.sh --wasm --agent --fake-shell \
--wasm-output /bin/hp_ssh.wasm \
--agent-output /bin/hp_agent \
--fake-shell-output /bin/fake-sh
RUN npm install -g pnpm@10 RUN chmod +x /bin/hp_ssh.wasm
RUN apk add --no-cache git RUN chmod +x /bin/hp_agent
COPY package.json pnpm-lock.yaml ./ RUN chmod +x /bin/fake-sh
COPY patches ./patches
RUN pnpm install --frozen-lockfile
COPY . . # Folder needs to exist for later stages
RUN pnpm run build
FROM node:22-alpine
RUN apk add --no-cache ca-certificates
RUN mkdir -p /var/lib/headplane
RUN mkdir -p /usr/libexec/headplane
RUN mkdir -p /var/lib/headplane/agent RUN mkdir -p /var/lib/headplane/agent
FROM --platform=$BUILDPLATFORM node:22.16-slim AS js-base
WORKDIR /run
RUN corepack enable
COPY patches ./patches
COPY package.json pnpm-lock.yaml build.sh ./
COPY --from=go-base /bin/hp_ssh.wasm /run/app/hp_ssh.wasm
COPY --from=go-base /bin/wasm_exec.js /run/app/wasm_exec.js
RUN ./build.sh --app --app-install-only
COPY . .
RUN ./build.sh --app
FROM gcr.io/distroless/nodejs22-debian12:latest AS final
COPY --from=js-base /run/build /app/build
COPY --from=js-base /run/drizzle /app/drizzle
COPY --from=js-base /run/node_modules /app/node_modules
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
COPY --from=go-base /var/lib/headplane /var/lib/headplane
# Fake shell to inform the user that they should use the debug image
COPY --from=go-base /bin/fake-sh /bin/sh
COPY --from=go-base /bin/fake-sh /bin/bash
WORKDIR /app WORKDIR /app
COPY --from=build /app/build /app/build CMD [ "/app/build/server/index.js" ]
COPY --from=agent-build /app/hp_agent /usr/libexec/headplane/agent
RUN chmod +x /usr/libexec/headplane/agent FROM node:22-alpine AS debug-shell
CMD [ "node", "./build/server/index.js" ] RUN apk add --no-cache bash curl
COPY --from=js-base /run/build /app/build
COPY --from=js-base /run/drizzle /app/drizzle
COPY --from=js-base /run/node_modules /app/node_modules
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
COPY --from=go-base /var/lib/headplane /var/lib/headplane
WORKDIR /app
CMD [ "node", "/app/build/server/index.js" ]
+12 -22
View File
@@ -4,15 +4,15 @@
<picture> <picture>
<source <source
media="(prefers-color-scheme: dark)" media="(prefers-color-scheme: dark)"
srcset="./assets/preview-dark.png" srcset="./docs/assets/preview-dark.png"
> >
<source <source
media="(prefers-color-scheme: light)" media="(prefers-color-scheme: light)"
srcset="./assets/preview-light.png" srcset="./docs/assets/preview-light.png"
> >
<img <img
alt="Preview" alt="Preview"
src="./assets/preview-dark.png" src="./docs/assets/preview-dark.png"
> >
</picture> </picture>
@@ -32,24 +32,14 @@ These are some of the features that Headplane offers:
- Configurability for Headscale's settings - Configurability for Headscale's settings
## Deployment ## Deployment
Headplane runs as a server-based web-application, meaning you'll need a server to run it. Refer to the [website](https://headplane.net) for detailed installation instructions.
It's available as a Docker image (recommended) or through a manual installation.
There are 2 ways to deploy Headplane:
- ### [Integrated Mode (Recommended)](/docs/Integrated-Mode.md) ## Versioning
Integrated mode unlocks all the features of Headplane and is the most
feature-complete deployment method. It communicates with Headscale directly.
- ### [Simple Mode](/docs/Simple-Mode.md)
Simple mode does not include the automatic management of DNS and Headplane
settings, requiring manual editing and reloading when making changes.
### Versioning
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0). 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 Pre-release builds are available under the `next` tag and get updated when a new release
PR is opened and actively in testing. PR is opened and actively in testing.
### Contributing ## Contributing
Headplane is an open-source project and contributions are welcome! If you have 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 any suggestions, bug reports, or feature requests, please open an issue. Also
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info. refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
@@ -59,30 +49,30 @@ refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
<picture> <picture>
<source <source
media="(prefers-color-scheme: dark)" media="(prefers-color-scheme: dark)"
srcset="./assets/acls-dark.png" srcset="./docs/assets/acls-dark.png"
> >
<source <source
media="(prefers-color-scheme: light)" media="(prefers-color-scheme: light)"
srcset="./assets/acls-light.png" srcset="./docs/assets/acls-light.png"
> >
<img <img
alt="ACLs" alt="ACLs"
src="./assets/acls-dark.png" src="./docs/assets/acls-dark.png"
> >
</picture> </picture>
<picture> <picture>
<source <source
media="(prefers-color-scheme: dark)" media="(prefers-color-scheme: dark)"
srcset="./assets/machine-dark.png" srcset="./docs/assets/machine-dark.png"
> >
<source <source
media="(prefers-color-scheme: light)" media="(prefers-color-scheme: light)"
srcset="./assets/machine-light.png" srcset="./docs/assets/machine-light.png"
> >
<img <img
alt="Machine Management" alt="Machine Management"
src="./assets/machine-dark.png" src="./docs/assets/machine-dark.png"
> >
</picture> </picture>
-15
View File
@@ -1,15 +0,0 @@
FROM golang:1.23 AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY agent/ ./agent
RUN CGO_ENABLED=0 GOOS=linux go build \
-trimpath \
-ldflags "-s -w" \
-o /app/hp_agent ./agent/cmd/hp_agent
FROM scratch
COPY --from=builder /app/hp_agent /hp_agent
ENTRYPOINT ["/hp_agent"]
-35
View File
@@ -1,35 +0,0 @@
package main
import (
_ "github.com/joho/godotenv/autoload"
"github.com/tale/headplane/agent/internal/config"
"github.com/tale/headplane/agent/internal/hpagent"
"github.com/tale/headplane/agent/internal/tsnet"
"github.com/tale/headplane/agent/internal/util"
)
type Register struct {
Type string
ID string
}
func main() {
log := util.GetLogger()
cfg, err := config.Load()
if err != nil {
log.Fatal("Failed to load config: %s", err)
}
log.SetDebug(cfg.Debug)
agent := tsnet.NewAgent(cfg)
agent.Connect()
defer agent.Shutdown()
log.Msg(&Register{
Type: "register",
ID: agent.ID,
})
hpagent.FollowMaster(agent)
}
-87
View File
@@ -1,87 +0,0 @@
package hpagent
import (
"bufio"
"encoding/json"
"os"
"sync"
"github.com/tale/headplane/agent/internal/tsnet"
"github.com/tale/headplane/agent/internal/util"
"tailscale.com/tailcfg"
)
// Represents messages from the Headplane master
type RecvMessage struct {
NodeIDs []string
}
type SendMessage struct {
Type string
Data any
}
// Starts listening for messages from stdin
func FollowMaster(agent *tsnet.TSAgent) {
log := util.GetLogger()
scanner := bufio.NewScanner(os.Stdin)
for scanner.Scan() {
line := scanner.Bytes()
var msg RecvMessage
err := json.Unmarshal(line, &msg)
if err != nil {
log.Error("Unable to unmarshal message: %s", err)
log.Debug("Full Error: %v", err)
continue
}
log.Debug("Recieved message from master: %v", line)
if len(msg.NodeIDs) == 0 {
log.Debug("Message recieved had no node IDs")
log.Debug("Full message: %s", line)
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 := agent.GetStatusForPeer(nodeID)
if err != nil {
log.Error("Unable to get status for node %s: %s", nodeID, err)
return
}
if result == nil {
log.Debug("No status for node %s", nodeID)
return
}
mu.Lock()
results[nodeID] = result
mu.Unlock()
}(nodeID)
}
wg.Wait()
// Send the results back to the Headplane master
log.Debug("Sending status back to master: %v", results)
log.Msg(&SendMessage{
Type: "status",
Data: results,
})
}
if err := scanner.Err(); err != nil {
log.Fatal("Error reading from stdin: %s", err)
}
}
-66
View File
@@ -1,66 +0,0 @@
package tsnet
import (
"context"
"encoding/hex"
"fmt"
"strings"
"github.com/tale/headplane/agent/internal/util"
"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) {
log := util.GetLogger()
if !strings.HasPrefix(id, "nodekey:") {
log.Debug("Node ID with missing prefix: %s", id)
return nil, fmt.Errorf("invalid node ID: %s", id)
}
log.Debug("Querying status of peer: %s", id)
status, err := s.Lc.Status(context.Background())
if err != nil {
log.Debug("Failed to get status: %s", err)
return nil, fmt.Errorf("failed to get status: %w", err)
}
// We need to convert from 64 char hex to 32 byte raw.
bytes, err := hex.DecodeString(id[8:])
if err != nil {
log.Debug("Failed to decode hex: %s", err)
return nil, fmt.Errorf("failed to decode hex: %w", err)
}
raw := mem.B(bytes)
if raw.Len() != 32 {
log.Debug("Invalid node ID length: %d", raw.Len())
return nil, fmt.Errorf("invalid node ID length: %d", raw.Len())
}
nodeKey := key.NodePublicFromRaw32(raw)
peer := status.Peer[nodeKey]
if peer == nil {
// Check if we are on Self.
if status.Self.PublicKey == nodeKey {
peer = status.Self
} else {
log.Debug("Peer not found in status: %s", id)
return nil, nil
}
}
ip := peer.TailscaleIPs[0].String()
whois, err := s.Lc.WhoIs(context.Background(), ip)
if err != nil {
log.Debug("Failed to get whois: %s", err)
return nil, fmt.Errorf("failed to get whois: %w", err)
}
log.Debug("Got whois for peer %s: %v", id, whois)
return &whois.Node.Hostinfo, nil
}
+3 -3
View File
@@ -38,7 +38,7 @@ export default function Attribute({
'min-w-0 px-1.5 py-1 rounded-lg border border-transparent', 'min-w-0 px-1.5 py-1 rounded-lg border border-transparent',
...(isCopyable ...(isCopyable
? [ ? [
'cursor-pointer hover:shadow-sm', 'cursor-pointer hover:shadow-xs',
'hover:bg-headplane-50 dark:hover:bg-headplane-800', 'hover:bg-headplane-50 dark:hover:bg-headplane-800',
'hover:border-headplane-100 dark:hover:border-headplane-700', 'hover:border-headplane-100 dark:hover:border-headplane-700',
] ]
@@ -70,8 +70,8 @@ export default function Attribute({
</div> </div>
{isCopyable ? ( {isCopyable ? (
<div> <div>
<Check className="size-4 hidden data-[copied]:block" /> <Check className="size-4 hidden data-copied:block" />
<Copy className="size-4 block data-[copied]:hidden" /> <Copy className="size-4 block data-copied:hidden" />
</div> </div>
) : undefined} ) : undefined}
</button> </button>
+1 -1
View File
@@ -20,7 +20,7 @@ export default function Button({ variant = 'light', ...props }: ButtonProps) {
{...buttonProps} {...buttonProps}
className={cn( className={cn(
'w-fit text-sm rounded-xl px-3 py-2', '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', props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy' ...(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', 'w-full max-w-md rounded-3xl p-5',
variant === 'flat' variant === 'flat'
? 'bg-transparent shadow-none' ? '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', 'border border-headplane-100 dark:border-headplane-800',
props.className, props.className,
)} )}
+4 -4
View File
@@ -13,7 +13,7 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
<code <code
className={cn( className={cn(
'bg-headplane-100 dark:bg-headplane-800 px-1 py-0.5 font-mono', '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', isCopyable && 'relative pr-7',
className, className,
)} )}
@@ -21,7 +21,6 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
{children} {children}
{isCopyable && ( {isCopyable && (
<button <button
type="button"
className="bottom-0 right-0 absolute" className="bottom-0 right-0 absolute"
onClick={async (event) => { onClick={async (event) => {
const text = Array.isArray(children) ? children.join('') : children; const text = Array.isArray(children) ? children.join('') : children;
@@ -40,9 +39,10 @@ export default function Code({ isCopyable, children, className }: CodeProps) {
} }
}, 1000); }, 1000);
}} }}
type="button"
> >
<Check className="h-4.5 w-4.5 p-1 hidden data-[copied]:block" /> <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" /> <Copy className="h-4.5 w-4.5 p-1 block data-copied:hidden" />
</button> </button>
)} )}
</code> </code>
+1 -1
View File
@@ -117,7 +117,7 @@ function Panel(props: DialogPanelProps) {
method={method ?? 'POST'} method={method ?? 'POST'}
ref={ref} ref={ref}
className={cn( 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', 'bg-white dark:bg-headplane-900',
)} )}
> >
+10 -7
View File
@@ -1,8 +1,9 @@
import { AlertIcon } from '@primer/octicons-react'; import { AlertCircle } from 'lucide-react';
import { isRouteErrorResponse, useRouteError } from 'react-router'; import { isRouteErrorResponse, useRouteError } from 'react-router';
import ResponseError from '~/server/headscale/api-error'; import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import Card from './Card'; import Card from './Card';
import Code from './Code';
interface Props { interface Props {
type?: 'full' | 'embedded'; type?: 'full' | 'embedded';
@@ -69,14 +70,16 @@ export function ErrorPopup({ type = 'full' }: Props) {
)} )}
> >
<Card> <Card>
<div className="flex items-center justify-between"> <div className="flex items-center gap-4">
<Card.Title className="text-3xl mb-0"> <AlertCircle className="w-8 h-8 text-red-500" />
{routing ? error.status : title} <div className="flex justify-between items-center gap-2 w-full">
</Card.Title> <Card.Title className="text-3xl mb-0">{title}</Card.Title>
<AlertIcon className="w-12 h-12 text-red-500" /> {routing && <Code className="text-2xl">{`${error.status}`}</Code>}
</div>
</div> </div>
<hr className="my-4 text-headplane-100 dark:text-headplane-800" />
<Card.Text <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} {routing ? error.data : message}
</Card.Text> </Card.Text>
+1 -1
View File
@@ -46,7 +46,7 @@ export default function Footer({ url, debug, healthy }: FooterProps) {
tabIndex={0} // Allows keyboard focus tabIndex={0} // Allows keyboard focus
className={cn( className={cn(
'blur-sm hover:blur-none focus:blur-none transition', 'blur-sm hover:blur-none focus:blur-none transition',
'focus:outline-none focus:ring-2 rounded-sm', 'focus:outline-hidden focus:ring-2 rounded-xs',
)} )}
> >
{url} {url}
+22 -22
View File
@@ -2,13 +2,13 @@ import {
CircleUser, CircleUser,
Globe2, Globe2,
Lock, Lock,
PlaneTakeoff,
Server, Server,
Settings, Settings,
Users, Users,
} from 'lucide-react'; } from 'lucide-react';
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { NavLink, useSubmit } from 'react-router'; import { NavLink, useSubmit } from 'react-router';
import Logo from '~/components/Logo';
import Menu from '~/components/Menu'; import Menu from '~/components/Menu';
import { AuthSession } from '~/server/web/sessions'; import { AuthSession } from '~/server/web/sessions';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
@@ -42,18 +42,18 @@ function TabLink({ name, to, icon }: TabLinkProps) {
return ( return (
<div className="relative py-2"> <div className="relative py-2">
<NavLink <NavLink
to={to}
prefetch="intent"
className={({ isActive }) => className={({ isActive }) =>
cn( cn(
'px-3 py-2 flex items-center rounded-md text-nowrap gap-x-2.5', '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:absolute after:bottom-0 after:left-3 after:right-3',
'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200', 'after:h-0.5 after:bg-headplane-900 dark:after:bg-headplane-200',
'hover:bg-headplane-200 dark:hover:bg-headplane-900', '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', isActive ? 'after:visible' : 'after:invisible',
) )
} }
prefetch="intent"
to={to}
> >
{icon} {name} {icon} {name}
</NavLink> </NavLink>
@@ -64,13 +64,13 @@ function TabLink({ name, to, icon }: TabLinkProps) {
function Link({ href, text }: LinkProps) { function Link({ href, text }: LinkProps) {
return ( return (
<a <a
href={href}
target="_blank"
rel="noreferrer"
className={cn( className={cn(
'hidden sm:block hover:underline text-sm', '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} {text}
</a> </a>
@@ -91,7 +91,7 @@ export default function Header(data: Props) {
> >
<div className="container flex items-center justify-between py-4"> <div className="container flex items-center justify-between py-4">
<div className="flex items-center gap-x-2"> <div className="flex items-center gap-x-2">
<PlaneTakeoff /> <Logo />
<h1 className="text-2xl font-semibold">headplane</h1> <h1 className="text-2xl font-semibold">headplane</h1>
</div> </div>
<div className="flex items-center gap-x-4"> <div className="flex items-center gap-x-4">
@@ -101,20 +101,21 @@ export default function Header(data: Props) {
{data.user ? ( {data.user ? (
<Menu> <Menu>
<Menu.IconButton <Menu.IconButton
label="User"
className={cn(data.user.picture ? 'p-0' : '')} className={cn(data.user.picture ? 'p-0' : '')}
label="User"
> >
{data.user.picture ? ( {data.user.picture ? (
<img <img
src={data.user.picture}
alt={data.user.name} alt={data.user.name}
className="w-8 h-8 rounded-full" className="w-8 h-8 rounded-full"
src={data.user.picture}
/> />
) : ( ) : (
<CircleUser /> <CircleUser />
)} )}
</Menu.IconButton> </Menu.IconButton>
<Menu.Panel <Menu.Panel
disabledKeys={['profile']}
onAction={(key) => { onAction={(key) => {
if (key === 'logout') { if (key === 'logout') {
submit( submit(
@@ -126,7 +127,6 @@ export default function Header(data: Props) {
); );
} }
}} }}
disabledKeys={['profile']}
> >
<Menu.Section> <Menu.Section>
<Menu.Item key="profile" textValue="Profile"> <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"> <nav className="container flex items-center gap-x-4 overflow-x-auto font-semibold">
{data.access.machines ? ( {data.access.machines ? (
<TabLink <TabLink
to="/machines"
name="Machines"
icon={<Server className="w-5" />} icon={<Server className="w-5" />}
name="Machines"
to="/machines"
/> />
) : undefined} ) : undefined}
{data.access.users ? ( {data.access.users ? (
<TabLink <TabLink
to="/users"
name="Users"
icon={<Users className="w-5" />} icon={<Users className="w-5" />}
name="Users"
to="/users"
/> />
) : undefined} ) : undefined}
{data.access.policy ? ( {data.access.policy ? (
<TabLink <TabLink
to="/acls"
name="Access Control"
icon={<Lock className="w-5" />} icon={<Lock className="w-5" />}
name="Access Control"
to="/acls"
/> />
) : undefined} ) : undefined}
{data.configAvailable ? ( {data.configAvailable ? (
<> <>
{data.access.dns ? ( {data.access.dns ? (
<TabLink <TabLink
to="/dns"
name="DNS"
icon={<Globe2 className="w-5" />} icon={<Globe2 className="w-5" />}
name="DNS"
to="/dns"
/> />
) : undefined} ) : undefined}
{data.access.settings ? ( {data.access.settings ? (
<TabLink <TabLink
to="/settings"
name="Settings"
icon={<Settings className="w-5" />} icon={<Settings className="w-5" />}
name="Settings"
to="/settings"
/> />
) : undefined} ) : undefined}
</> </>
+1 -1
View File
@@ -25,7 +25,7 @@ export default function IconButton({
aria-label={props.label} aria-label={props.label}
className={cn( className={cn(
'rounded-full flex items-center justify-center p-1', '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', props.isDisabled && 'opacity-60 cursor-not-allowed',
...(variant === 'heavy' ...(variant === 'heavy'
? [ ? [
+1 -1
View File
@@ -54,7 +54,7 @@ export default function Input(props: InputProps) {
ref={ref} ref={ref}
className={cn( className={cn(
'rounded-xl px-3 py-2', 'rounded-xl px-3 py-2',
'focus:outline-none focus:ring', 'focus:outline-hidden focus:ring-3',
'bg-white dark:bg-headplane-900', 'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800', 'border border-headplane-100 dark:border-headplane-800',
className, className,
+1 -1
View File
@@ -24,7 +24,7 @@ export default function Link({
'inline-flex items-center gap-x-0.5', 'inline-flex items-center gap-x-0.5',
'text-blue-500 hover:text-blue-700', 'text-blue-500 hover:text-blue-700',
'dark:text-blue-400 dark:hover:text-blue-300', '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, 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 <ul
{...menuProps} {...menuProps}
ref={ref} 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) => ( {[...state.collection].map((item) => (
<MenuSection <MenuSection
@@ -149,7 +149,7 @@ function MenuItem<T>({ item, state, isDisabled }: MenuItemProps<T>) {
ref={ref} ref={ref}
className={cn( className={cn(
'py-2 px-3 mx-1 rounded-lg', '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', isFocused && 'bg-headplane-100/50 dark:bg-headplane-800',
isDisabled isDisabled
? 'text-headplane-400 dark:text-headplane-600' ? 'text-headplane-400 dark:text-headplane-600'
+2 -2
View File
@@ -50,7 +50,7 @@ export default function NumberInput(props: InputProps) {
{...groupProps} {...groupProps}
className={cn( className={cn(
'flex items-center gap-1 rounded-xl pr-1', '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', 'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800', 'border border-headplane-100 dark:border-headplane-800',
)} )}
@@ -60,7 +60,7 @@ export default function NumberInput(props: InputProps) {
required={props.isRequired} required={props.isRequired}
ref={ref} ref={ref}
id={id} 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} /> <input type="hidden" name={name} value={state.numberValue} />
<IconButton <IconButton
+1 -1
View File
@@ -52,7 +52,7 @@ function Option({ item, state }: OptionsOptionProps) {
className={cn( className={cn(
'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer', 'pl-0.5 pr-2 py-0.5 rounded-lg cursor-pointer',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950', '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', 'border border-headplane-100 dark:border-headplane-800',
)} )}
> >
+1 -1
View File
@@ -34,7 +34,7 @@ export default function Popover(props: PopoverProps) {
{...popoverProps} {...popoverProps}
ref={ref} ref={ref}
className={cn( className={cn(
'z-10 shadow-sm rounded-xl', 'z-10 shadow-xs rounded-xl',
'bg-white dark:bg-headplane-900', 'bg-white dark:bg-headplane-900',
'border border-headplane-200 dark:border-headplane-800', 'border border-headplane-200 dark:border-headplane-800',
className, className,
+4 -4
View File
@@ -60,7 +60,7 @@ function Select(props: SelectProps) {
</label> </label>
<div <div
className={cn( 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', 'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800', 'border border-headplane-100 dark:border-headplane-800',
)} )}
@@ -69,7 +69,7 @@ function Select(props: SelectProps) {
{...inputProps} {...inputProps}
ref={inputRef} ref={inputRef}
id={id} 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 data-1p-ignore
/> />
<button <button
@@ -127,7 +127,7 @@ function ListBox(props: ListBoxProps) {
<ul <ul
{...listBoxProps} {...listBoxProps}
ref={listBoxRef} 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) => ( {[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} /> <Option key={item.key} item={item} state={state} />
@@ -158,7 +158,7 @@ function Option({ item, state }: OptionProps) {
className={cn( className={cn(
'flex items-center justify-between', 'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1', 'py-2 px-3 mx-1 rounded-lg mb-1',
'focus:outline-none select-none', 'focus:outline-hidden select-none',
isFocused || isSelected isFocused || isSelected
? 'bg-headplane-100/50 dark:bg-headplane-800' ? 'bg-headplane-100/50 dark:bg-headplane-800'
: 'hover:bg-headplane-100/50 dark:hover: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 { export interface SwitchProps extends AriaSwitchProps {
label: string; label: string;
className?: string; className?: string;
switchClassName?: string;
} }
export default function Switch(props: SwitchProps) { 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', state.isSelected && 'bg-headplane-900 dark:bg-headplane-950',
isFocusVisible && 'ring-2', isFocusVisible && 'ring-2',
props.isDisabled && 'opacity-50', props.isDisabled && 'opacity-50',
props.className,
)} )}
> >
<span <span
className={cn( className={cn(
'h-[18px] w-[18px] transform rounded-full', 'h-[18px] w-[18px] transform rounded-full',
'bg-white transition duration-50 ease-in-out', 'bg-white transition duration-50 ease-in-out',
'translate-x-0 group-selected:translate-x-[100%]', 'translate-x-0 group-selected:translate-x-full',
state.isSelected && 'translate-x-[100%]', state.isSelected && 'translate-x-full',
props.switchClassName,
)} )}
/> />
</div> </div>
+1 -1
View File
@@ -56,7 +56,7 @@ function Tab({ item, state }: TabsTabProps) {
className={cn( className={cn(
'pl-2 pr-3 py-2.5', 'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950', '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', 'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0', '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} {...triggerProps}
className={cn( className={cn(
'flex items-center justify-center', 'flex items-center justify-center',
'focus:outline-none focus:ring rounded-xl', 'focus:outline-hidden focus:ring-3 rounded-xl',
)} )}
> >
{component} {component}
@@ -65,7 +65,7 @@ function Body({ state, className, ...props }: TooltipBodyProps) {
{...mergeProps(props, tooltipProps)} {...mergeProps(props, tooltipProps)}
className={cn( className={cn(
'absolute z-50 p-3 top-full mt-1', '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', 'bg-white dark:bg-headplane-950',
'text-black dark:text-white', 'text-black dark:text-white',
'shadow-lg dark:shadow-md rounded-xl', 'shadow-lg dark:shadow-md rounded-xl',
+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>
);
}
+10 -19
View File
@@ -1,37 +1,30 @@
import { XCircleFillIcon } from '@primer/octicons-react'; import { type LoaderFunctionArgs, Outlet, redirect } from 'react-router';
import { ServerCrash } from 'lucide-react'; import { ErrorPopup } from '~/components/Error';
import {
type LoaderFunctionArgs,
isRouteErrorResponse,
redirect,
useRouteError,
} from 'react-router';
import { Outlet, useLoaderData } from 'react-router';
import Card from '~/components/Card';
import { ErrorPopup, getErrorMessage } from '~/components/Error';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { pruneEphemeralNodes } from '~/server/db/pruner';
import ResponseError from '~/server/headscale/api-error'; import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import log from '~/utils/log'; import log from '~/utils/log';
export async function loader({ export async function loader({
request, request,
context, context,
...rest
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck(); const healthy = await context.client.healthcheck();
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
await pruneEphemeralNodes({ context, request, ...rest });
// We shouldn't session invalidate if Headscale is down // 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 // TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
if (healthy) { if (healthy) {
try { try {
await context.client.get('v1/apikey', session.get('api_key')!); await context.client.get('v1/apikey', session.api_key);
} catch (error) { } catch (error) {
if (error instanceof ResponseError) { if (error instanceof ResponseError) {
log.debug('api', 'API Key validation failed %o', error); log.debug('api', 'API Key validation failed %o', error);
return redirect('/login', { return redirect('/login', {
headers: { headers: {
'Set-Cookie': await context.sessions.destroy(session), 'Set-Cookie': await context.sessions.destroySession(),
}, },
}); });
} }
@@ -45,11 +38,9 @@ export async function loader({
export default function Layout() { export default function Layout() {
return ( return (
<> <main className="container mx-auto overscroll-contain mt-4 mb-24">
<main className="container mx-auto overscroll-contain mt-4 mb-24"> <Outlet />
<Outlet /> </main>
</main>
</>
); );
} }
+19 -70
View File
@@ -1,3 +1,4 @@
import { eq } from 'drizzle-orm';
import { CircleCheckIcon } from 'lucide-react'; import { CircleCheckIcon } from 'lucide-react';
import { import {
LoaderFunctionArgs, LoaderFunctionArgs,
@@ -10,9 +11,8 @@ import Card from '~/components/Card';
import Footer from '~/components/Footer'; import Footer from '~/components/Footer';
import Header from '~/components/Header'; import Header from '~/components/Header';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { users } from '~/server/db/schema';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import { User } from '~/types';
import log from '~/utils/log';
import toast from '~/utils/toast'; import toast from '~/utils/toast';
// This loads the bare minimum for the application to function // This loads the bare minimum for the application to function
@@ -23,72 +23,18 @@ export async function loader({
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
try { try {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
if (!session.has('api_key')) { if (
// There is a session, but it's not valid context.oidc &&
return redirect('/login', { session.user.subject !== 'unknown-non-oauth' &&
headers: { !request.url.endsWith('/onboarding')
'Set-Cookie': await context.sessions.destroy(session), ) {
}, 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 (!user?.onboarded) {
if (context.oidc && !request.url.endsWith('/onboarding')) {
let onboarded = false;
const sessionUser = session.get('user');
if (sessionUser) {
if (context.sessions.onboardForSubject(sessionUser.subject)) {
// Assume onboarded
onboarded = true;
} else {
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) {
return redirect('/onboarding'); return redirect('/onboarding');
} }
} }
@@ -99,7 +45,7 @@ export async function loader({
url: context.config.headscale.public_url ?? context.config.headscale.url, url: context.config.headscale.public_url ?? context.config.headscale.url,
configAvailable: context.hs.readable(), configAvailable: context.hs.readable(),
debug: context.config.debug, debug: context.config.debug,
user: session.get('user'), user: session.user,
uiAccess: check, uiAccess: check,
access: { access: {
ui: await context.sessions.check(request, Capabilities.ui_access), ui: await context.sessions.check(request, Capabilities.ui_access),
@@ -119,8 +65,11 @@ export async function loader({
healthy: await context.client.healthcheck(), healthy: await context.client.healthcheck(),
}; };
} catch { } catch {
// No session, so we can just return return redirect('/login', {
return redirect('/login'); headers: {
'Set-Cookie': await context.sessions.destroySession(),
},
});
} }
} }
+4 -1
View File
@@ -8,6 +8,7 @@ import {
useNavigation, useNavigation,
} from 'react-router'; } from 'react-router';
import '@fontsource-variable/inter'; import '@fontsource-variable/inter';
import { ExternalScripts } from 'remix-utils/external-scripts';
import { ErrorPopup } from '~/components/Error'; import { ErrorPopup } from '~/components/Error';
import ProgressBar from '~/components/ProgressBar'; import ProgressBar from '~/components/ProgressBar';
import ToastProvider from '~/components/ToastProvider'; import ToastProvider from '~/components/ToastProvider';
@@ -38,15 +39,17 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<html lang="en"> <html lang="en">
<head> <head>
<meta charSet="utf-8" /> <meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta content="width=device-width, initial-scale=1" name="viewport" />
<Meta /> <Meta />
<Links /> <Links />
<link href="favicon.ico" rel="icon" />
</head> </head>
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50"> <body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
{children} {children}
<ToastProvider queue={toastQueue} /> <ToastProvider queue={toastQueue} />
<ScrollRestoration /> <ScrollRestoration />
<Scripts /> <Scripts />
<ExternalScripts />
</body> </body>
</html> </html>
</LiveDataProvider> </LiveDataProvider>
+1
View File
@@ -10,6 +10,7 @@ export default [
route('/logout', 'routes/auth/logout.ts'), route('/logout', 'routes/auth/logout.ts'),
route('/oidc/callback', 'routes/auth/oidc-callback.ts'), route('/oidc/callback', 'routes/auth/oidc-callback.ts'),
route('/oidc/start', 'routes/auth/oidc-start.ts'), route('/oidc/start', 'routes/auth/oidc-start.ts'),
route('/ssh', 'routes/ssh/console.tsx'),
// All the main logged-in dashboard routes // All the main logged-in dashboard routes
// Double nested to separate error propagations // Double nested to separate error propagations
+1 -1
View File
@@ -31,7 +31,7 @@ export async function aclAction({
const { policy, updatedAt } = await context.client.put<{ const { policy, updatedAt } = await context.client.put<{
policy: string; policy: string;
updatedAt: string; updatedAt: string;
}>('v1/policy', session.get('api_key')!, { }>('v1/policy', session.api_key, {
policy: policyData, policy: policyData,
}); });
+1 -1
View File
@@ -34,7 +34,7 @@ export async function aclLoader({
const { policy, updatedAt } = await context.client.get<{ const { policy, updatedAt } = await context.client.get<{
policy: string; policy: string;
updatedAt: string | null; updatedAt: string | null;
}>('v1/policy', session.get('api_key')!); }>('v1/policy', session.api_key);
// Successfully loaded the policy, mark it as readable // Successfully loaded the policy, mark it as readable
// If `updatedAt` is null, it means the policy is in file mode. // If `updatedAt` is null, it means the policy is in file mode.
+2 -2
View File
@@ -66,8 +66,8 @@ export default function Page() {
The ACL policy mode is most likely set to <Code>file</Code> in your 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 Headscale configuration. This means that the ACL file cannot be edited
through the web interface. In order to resolve this, you'll need to 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 set <Code>policy.mode</Code> to <Code>database</Code> in your
configuration. Headscale configuration.
</Notice> </Notice>
) : undefined} ) : undefined}
<h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1> <h1 className="text-2xl font-medium mb-4">Access Control List (ACL)</h1>
+11 -13
View File
@@ -60,25 +60,23 @@ export async function loginAction({
}; };
} }
// Set the session
const session = await context.sessions.getOrCreate(request);
const expiresDays = Math.round( const expiresDays = Math.round(
(expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24, (expiry.getTime() - Date.now()) / 1000 / 60 / 60 / 24,
); );
session.set('state', 'auth');
session.set('api_key', apiKey);
session.set('user', {
subject: 'unknown-non-oauth',
name: `${lookup.prefix}...`,
email: `expires@${expiresDays.toString()}-days`,
});
return redirect('/machines', { return redirect('/machines', {
headers: { headers: {
'Set-Cookie': await context.sessions.commit(session, { 'Set-Cookie': await context.sessions.createSession(
maxAge: expiry.getTime() - Date.now(), {
}), api_key: apiKey,
user: {
subject: 'unknown-non-oauth',
name: `${lookup.prefix}...`,
email: `expires@${expiresDays.toString()}-days`,
},
},
expiry.getTime() - Date.now(),
),
}, },
}); });
} catch (error) { } catch (error) {
+7 -9
View File
@@ -1,10 +1,10 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { import {
ActionFunctionArgs, ActionFunctionArgs,
data,
Form, Form,
LoaderFunctionArgs, LoaderFunctionArgs,
Link as RemixLink, Link as RemixLink,
data,
redirect, redirect,
useActionData, useActionData,
useLoaderData, useLoaderData,
@@ -24,10 +24,8 @@ export async function loader({
context, context,
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
try { try {
const session = await context.sessions.auth(request); await context.sessions.auth(request);
if (session.has('api_key')) { return redirect('/machines');
return redirect('/machines');
}
} catch {} } catch {}
const qp = new URL(request.url).searchParams; const qp = new URL(request.url).searchParams;
@@ -104,26 +102,26 @@ export default function Page() {
terminal. terminal.
</Card.Text> </Card.Text>
<Input <Input
className="mt-8 mb-2"
isRequired isRequired
labelHidden
label="API Key" label="API Key"
labelHidden
name="api_key" name="api_key"
placeholder="API Key" placeholder="API Key"
type="password" type="password"
className="mt-8 mb-2"
/> />
{formData?.success === false ? ( {formData?.success === false ? (
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300"> <Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
{formData.message} {formData.message}
</Card.Text> </Card.Text>
) : undefined} ) : undefined}
<Button className="w-full" variant="heavy" type="submit"> <Button className="w-full" type="submit" variant="heavy">
Sign In Sign In
</Button> </Button>
</Form> </Form>
{oidc ? ( {oidc ? (
<RemixLink to="/oidc/start"> <RemixLink to="/oidc/start">
<Button variant="light" className="w-full mt-2"> <Button className="w-full mt-2" variant="light">
Single Sign-On Single Sign-On
</Button> </Button>
</RemixLink> </RemixLink>
+5 -4
View File
@@ -9,9 +9,10 @@ export async function action({
request, request,
context, context,
}: ActionFunctionArgs<LoadContext>) { }: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); try {
if (!session.has('api_key')) { await context.sessions.auth(request);
return redirect('/login'); } catch {
redirect('/login');
} }
// When API key is disabled, we need to explicitly redirect // When API key is disabled, we need to explicitly redirect
@@ -22,7 +23,7 @@ export async function action({
return redirect(url, { return redirect(url, {
headers: { 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 { LoadContext } from '~/server';
import type { AuthSession, OidcFlowSession } from '~/server/web/sessions'; import { HeadplaneConfig } from '~/server/config/schema';
import { finishAuthFlow, formatError } from '~/utils/oidc'; import { users } from '~/server/db/schema';
import { Roles } from '~/server/web/roles';
import { FlowUser, finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res'; import { send } from '~/utils/res';
interface OidcFlowSession {
state: string;
nonce: string;
code_verifier: string;
redirect_uri: string;
}
export async function loader({ export async function loader({
request, request,
context, context,
@@ -18,13 +30,21 @@ export async function loader({
return redirect('/login'); return redirect('/login');
} }
const session = await context.sessions.getOrCreate<OidcFlowSession>(request); const cookie = createCookie('__oidc_auth_flow', {
if (session.get('state') !== 'flow') { httpOnly: true,
return redirect('/login'); // Haven't started an OIDC flow 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 } = data;
const { code_verifier, state, nonce, redirect_uri } = payload;
if (!code_verifier || !state || !nonce || !redirect_uri) { if (!code_verifier || !state || !nonce || !redirect_uri) {
return send({ error: 'Missing OIDC state' }, { status: 400 }); return send({ error: 'Missing OIDC state' }, { status: 400 });
} }
@@ -42,20 +62,39 @@ export async function loader({
}; };
try { try {
const user = await finishAuthFlow(context.oidc, flowOptions); let user = await finishAuthFlow(context.oidc, flowOptions);
session.unset('oidc'); user = {
const userSession = session as Session<AuthSession>; ...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', { return redirect('/machines', {
headers: { 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) { } 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 type { LoadContext } from '~/server';
import { AuthSession, OidcFlowSession } from '~/server/web/sessions';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc'; import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
export async function loader({ export async function loader({
request, request,
context, context,
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.getOrCreate<OidcFlowSession>(request); try {
if ((session as Session<AuthSession>).has('api_key')) { await context.sessions.auth(request);
return redirect('/machines'); return redirect('/machines');
} } catch {}
if (!context.oidc) { if (!context.oidc || !context.config.oidc) {
throw new Error('OIDC is not enabled'); throw new Error('OIDC is not enabled');
} }
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300, // 5 minutes
});
const redirectUri = const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request); context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow( const data = await beginAuthFlow(
context.oidc, context.oidc,
redirectUri, redirectUri,
// We can't get here without the OIDC config being defined context.config.oidc.scope,
context.config.oidc!.token_endpoint_auth_method, 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, { return redirect(data.url, {
status: 302, status: 302,
headers: { 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} ref={setNodeRef}
className={cn( className={cn(
isSortableDragging ? 'opacity-50' : '', isSortableDragging ? 'opacity-50' : '',
isDragging ? 'ring bg-white dark:bg-headplane-900' : '', isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
)} )}
style={{ style={{
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@@ -179,7 +179,7 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
<GripVertical <GripVertical
{...attributes} {...attributes}
{...listeners} {...listeners}
className="p-0.5 focus:ring outline-none rounded-md" className="p-0.5 focus:ring-3 outline-hidden rounded-md"
/> />
)} )}
{domain} {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 Button from '~/components/Button';
import Link from '~/components/Link'; import Link from '~/components/Link';
import Switch from '~/components/Switch';
import TableList from '~/components/TableList'; import TableList from '~/components/TableList';
import Tooltip from '~/components/Tooltip';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import AddNS from '../dialogs/add-ns'; import AddNS from '../dialogs/add-ns';
interface Props { interface Props {
nameservers: Record<string, string[]>; nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
isDisabled: boolean; isDisabled: boolean;
} }
export default function ManageNS({ nameservers, isDisabled }: Props) { export default function ManageNS({
nameservers,
isDisabled,
overrideLocalDns,
}: Props) {
return ( return (
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1> <h1 className="text-2xl font-medium mb-4">Nameservers</h1>
@@ -31,6 +39,7 @@ export default function ManageNS({ nameservers, isDisabled }: Props) {
isGlobal={key === 'global'} isGlobal={key === 'global'}
isDisabled={isDisabled} isDisabled={isDisabled}
nameservers={nameservers} nameservers={nameservers}
overrideLocalDns={overrideLocalDns}
name={key} name={key}
/> />
))} ))}
@@ -45,6 +54,7 @@ interface ListProps {
isGlobal: boolean; isGlobal: boolean;
isDisabled: boolean; isDisabled: boolean;
nameservers: Record<string, string[]>; nameservers: Record<string, string[]>;
overrideLocalDns: boolean;
name: string; name: string;
} }
@@ -52,6 +62,7 @@ function NameserverList({
isGlobal, isGlobal,
isDisabled, isDisabled,
nameservers, nameservers,
overrideLocalDns,
name, name,
}: ListProps) { }: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name]; const list = isGlobal ? nameservers.global : nameservers[name];
@@ -59,12 +70,54 @@ function NameserverList({
return null; return null;
} }
const submit = useSubmit();
return ( return (
<div className="mb-8"> <div className="mb-8">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<h2 className="text-md font-medium opacity-80"> {isGlobal ? (
{isGlobal ? 'Global Nameservers' : name} <div className="flex items-center justify-between w-full">
</h2> <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> </div>
<TableList> <TableList>
{list.length > 0 {list.length > 0
+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 { useMemo, useState } from 'react';
import Chip from '~/components/Chip'; import Chip from '~/components/Chip';
import Dialog from '~/components/Dialog'; import Dialog from '~/components/Dialog';
@@ -35,15 +35,15 @@ export default function AddNameserver({ nameservers }: Props) {
<Dialog.Button>Add nameserver</Dialog.Button> <Dialog.Button>Add nameserver</Dialog.Button>
<Dialog.Panel> <Dialog.Panel>
<Dialog.Title className="mb-4">Add nameserver</Dialog.Title> <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 <Input
description="Use this IPv4 or IPv6 address to resolve names."
isInvalid={isInvalid}
isRequired isRequired
label="Nameserver" label="Nameserver"
description="Use this IPv4 or IPv6 address to resolve names."
placeholder="1.2.3.4"
name="ns" name="ns"
onChange={setNs} onChange={setNs}
isInvalid={isInvalid} placeholder="1.2.3.4"
/> />
<div className="flex items-center justify-between mt-8"> <div className="flex items-center justify-between mt-8">
<div className="block"> <div className="block">
@@ -53,9 +53,9 @@ export default function AddNameserver({ nameservers }: Props) {
</Dialog.Text> </Dialog.Text>
<Tooltip> <Tooltip>
<Chip <Chip
text="Split DNS"
leftIcon={<RepoForkedIcon className="w-4 h-4 mr-0.5" />}
className={cn('inline-flex items-center')} className={cn('inline-flex items-center')}
leftIcon={<Split className="w-3 h-3 mr-0.5" />}
text="Split DNS"
/> />
<Tooltip.Body> <Tooltip.Body>
Only clients that support split DNS (Tailscale v1.8 or later Only clients that support split DNS (Tailscale v1.8 or later
@@ -76,9 +76,9 @@ export default function AddNameserver({ nameservers }: Props) {
<Input <Input
isRequired={split === true} isRequired={split === true}
label="Domain" label="Domain"
placeholder="example.com"
name="split_name" name="split_name"
onChange={setDomain} onChange={setDomain}
placeholder="example.com"
/> />
<Dialog.Text className="text-sm"> <Dialog.Text className="text-sm">
Only single-label or fully-qualified queries matching this suffix Only single-label or fully-qualified queries matching this suffix
@@ -86,7 +86,7 @@ export default function AddNameserver({ nameservers }: Props) {
</Dialog.Text> </Dialog.Text>
</> </>
) : ( ) : (
<input type="hidden" name="split_name" value="global" /> <input name="split_name" type="hidden" value="global" />
)} )}
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
+20 -1
View File
@@ -42,6 +42,8 @@ export async function dnsAction({
return removeRecord(formData, context); return removeRecord(formData, context);
case 'add_record': case 'add_record':
return addRecord(formData, context); return addRecord(formData, context);
case 'override_dns':
return overrideDns(formData, context);
default: default:
return data({ success: false }, 400); return data({ success: false }, 400);
} }
@@ -104,7 +106,7 @@ async function removeNs(formData: FormData, context: LoadContext) {
await context.hs.patch([ await context.hs.patch([
{ {
path: `dns.nameservers.split."${splitName}"`, path: `dns.nameservers.split."${splitName}"`,
value: servers, value: servers.length > 0 ? servers : null,
}, },
]); ]);
} }
@@ -230,3 +232,20 @@ async function addRecord(formData: FormData, context: LoadContext) {
await context.integration?.onConfigChange(context.client); await context.integration?.onConfigChange(context.client);
} }
async function overrideDns(formData: FormData, context: LoadContext) {
const override = formData.get('override_dns')?.toString();
if (!override) {
return data({ success: false }, 400);
}
const overrideValue = override === 'true';
await context.hs.patch([
{
path: 'dns.override_local_dns',
value: overrideValue,
},
]);
await context.integration?.onConfigChange(context.client);
}
+7 -2
View File
@@ -44,6 +44,7 @@ export async function loader({
nameservers: config.dns.nameservers.global, nameservers: config.dns.nameservers.global,
splitDns: config.dns.nameservers.split, splitDns: config.dns.nameservers.split,
searchDomains: config.dns.search_domains, searchDomains: config.dns.search_domains,
overrideDns: config.dns.override_local_dns,
extraRecords: context.hs.d, extraRecords: context.hs.d,
}; };
@@ -70,7 +71,7 @@ export default function Page() {
const isDisabled = data.access === false || data.writable === false; const isDisabled = data.access === false || data.writable === false;
return ( 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 : ( {data.writable ? undefined : (
<Notice> <Notice>
The Headscale configuration is read-only. You cannot make changes to The Headscale configuration is read-only. You cannot make changes to
@@ -84,7 +85,11 @@ export default function Page() {
</Notice> </Notice>
)} )}
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} /> <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} /> <ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
<ManageDomains <ManageDomains
searchDomains={data.searchDomains} searchDomains={data.searchDomains}
+29 -20
View File
@@ -1,17 +1,17 @@
import { ChevronDownIcon, CopyIcon } from '@primer/octicons-react'; import { ChevronDown, Copy } from 'lucide-react';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import Chip from '~/components/Chip'; import Chip from '~/components/Chip';
import Menu from '~/components/Menu'; import Menu from '~/components/Menu';
import StatusCircle from '~/components/StatusCircle'; import StatusCircle from '~/components/StatusCircle';
import type { User } from '~/types';
import cn from '~/utils/cn';
import * as hinfo from '~/utils/host-info';
import { ExitNodeTag } from '~/components/tags/ExitNode'; import { ExitNodeTag } from '~/components/tags/ExitNode';
import { ExpiryTag } from '~/components/tags/Expiry'; import { ExpiryTag } from '~/components/tags/Expiry';
import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent'; import { HeadplaneAgentTag } from '~/components/tags/HeadplaneAgent';
import { SubnetTag } from '~/components/tags/Subnet'; 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 { PopulatedNode } from '~/utils/node-info';
import toast from '~/utils/toast'; import toast from '~/utils/toast';
import MenuOptions from './menu'; import MenuOptions from './menu';
@@ -46,24 +46,26 @@ export default function MachineRow({
return ( return (
<tr <tr
key={node.id}
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" 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 <Link
className={cn('group/link h-full focus:outline-hidden')}
to={`/machines/${node.id}`} to={`/machines/${node.id}`}
className={cn('group/link h-full focus:outline-none')}
> >
<p <p
className={cn( className={cn(
'font-semibold leading-snug', 'font-semibold leading-snug',
'group-hover/link:text-blue-600', 'group-hover/link:text-blue-600',
'group-hover/link:dark:text-blue-400', 'dark:group-hover/link:text-blue-400',
)} )}
> >
{node.givenName} {node.givenName}
</p> </p>
<p className="text-sm opacity-50">{node.user.name}</p> <p className="text-sm opacity-50">
{node.user.name || node.user.displayName || node.user.email || node.user.id}
</p>
<div className="flex gap-1 flex-wrap mt-1.5"> <div className="flex gap-1 flex-wrap mt-1.5">
{mapTagsToComponents(node, uiTags)} {mapTagsToComponents(node, uiTags)}
{node.validTags.map((tag) => ( {node.validTags.map((tag) => (
@@ -77,7 +79,7 @@ export default function MachineRow({
{node.ipAddresses[0]} {node.ipAddresses[0]}
<Menu placement="bottom end"> <Menu placement="bottom end">
<Menu.IconButton className="bg-transparent" label="IP Addresses"> <Menu.IconButton className="bg-transparent" label="IP Addresses">
<ChevronDownIcon className="w-4 h-4" /> <ChevronDown className="w-4 h-4" />
</Menu.IconButton> </Menu.IconButton>
<Menu.Panel <Menu.Panel
onAction={async (key) => { onAction={async (key) => {
@@ -95,7 +97,7 @@ export default function MachineRow({
)} )}
> >
{ip} {ip}
<CopyIcon className="w-3 h-3" /> <Copy className="w-3 h-3" />
</div> </div>
</Menu.Item> </Menu.Item>
))} ))}
@@ -129,8 +131,8 @@ export default function MachineRow({
)} )}
> >
<StatusCircle <StatusCircle
isOnline={node.online && !node.expired}
className="w-4 h-4" className="w-4 h-4"
isOnline={node.online && !node.expired}
/> />
<p suppressHydrationWarning> <p suppressHydrationWarning>
{node.online && !node.expired {node.online && !node.expired
@@ -141,10 +143,10 @@ export default function MachineRow({
</td> </td>
<td className="py-2 pr-0.5"> <td className="py-2 pr-0.5">
<MenuOptions <MenuOptions
isDisabled={isDisabled}
magic={magic}
node={node} node={node}
users={users} users={users}
magic={magic}
isDisabled={isDisabled}
/> />
</td> </td>
</tr> </tr>
@@ -175,6 +177,10 @@ export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
uiTags.push('subnet-approved'); uiTags.push('subnet-approved');
} }
if (node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0) {
uiTags.push('tailscale-ssh');
}
if (isAgent === true) { if (isAgent === true) {
uiTags.push('headplane-agent'); uiTags.push('headplane-agent');
} }
@@ -187,27 +193,30 @@ export function mapTagsToComponents(node: PopulatedNode, uiTags: string[]) {
switch (tag) { switch (tag) {
case 'exit-approved': case 'exit-approved':
case 'exit-waiting': case 'exit-waiting':
return <ExitNodeTag key={tag} isEnabled={tag === 'exit-approved'} />; return <ExitNodeTag isEnabled={tag === 'exit-approved'} key={tag} />;
case 'subnet-approved': case 'subnet-approved':
case 'subnet-waiting': case 'subnet-waiting':
return <SubnetTag key={tag} isEnabled={tag === 'subnet-approved'} />; return <SubnetTag isEnabled={tag === 'subnet-approved'} key={tag} />;
case 'expired': case 'expired':
case 'no-expiry': case 'no-expiry':
return ( return (
<ExpiryTag <ExpiryTag
expiry={node.expiry ?? undefined}
key={tag} key={tag}
variant={tag} variant={tag}
expiry={node.expiry ?? undefined}
/> />
); );
case 'tailscale-ssh':
return <TailscaleSSHTag key={tag} />;
case 'headplane-agent': case 'headplane-agent':
return <HeadplaneAgentTag />; return <HeadplaneAgentTag key={tag} />;
default: default:
return; return null;
} }
}); });
} }
+50 -4
View File
@@ -1,5 +1,6 @@
import { Cog, Ellipsis } from 'lucide-react'; import { Cog, Ellipsis, SquareTerminal } from 'lucide-react';
import { useState } from 'react'; import { useState } from 'react';
import Button from '~/components/Button';
import Menu from '~/components/Menu'; import Menu from '~/components/Menu';
import type { User } from '~/types'; import type { User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
@@ -10,7 +11,6 @@ import Move from '../dialogs/move';
import Rename from '../dialogs/rename'; import Rename from '../dialogs/rename';
import Routes from '../dialogs/routes'; import Routes from '../dialogs/routes';
import Tags from '../dialogs/tags'; import Tags from '../dialogs/tags';
interface MenuProps { interface MenuProps {
node: PopulatedNode; node: PopulatedNode;
users: User[]; users: User[];
@@ -29,8 +29,11 @@ export default function MachineMenu({
isDisabled, isDisabled,
}: MenuProps) { }: MenuProps) {
const [modal, setModal] = useState<Modal>(null); const [modal, setModal] = useState<Modal>(null);
const supportsTailscaleSSH =
node.hostInfo?.sshHostKeys && node.hostInfo?.sshHostKeys.length > 0;
return ( return (
<> <div className="flex items-center justify-end px-4 gap-1.5">
{modal === 'remove' && ( {modal === 'remove' && (
<Delete <Delete
machine={node} machine={node}
@@ -88,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}> <Menu isDisabled={isDisabled}>
{isFullButton ? ( {isFullButton ? (
<Menu.Button className="flex items-center gap-x-2"> <Menu.Button className="flex items-center gap-x-2">
@@ -126,6 +172,6 @@ export default function MachineMenu({
</Menu.Section> </Menu.Section>
</Menu.Panel> </Menu.Panel>
</Menu> </Menu>
</> </div>
); );
} }
+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 This will disconnect the machine from your Tailnet. In order to
reconnect, you will need to re-authenticate from the device. reconnect, you will need to re-authenticate from the device.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="action_id" value="expire" /> <input name="action_id" type="hidden" value="expire" />
<input type="hidden" name="node_id" value={machine.id} /> <input name="node_id" type="hidden" value={machine.id} />
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
); );
+1 -1
View File
@@ -34,7 +34,7 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
}} }}
> >
{users.map((user) => ( {users.map((user) => (
<Select.Item key={user.id}>{user.name}</Select.Item> <Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
))} ))}
</Select> </Select>
</Dialog.Panel> </Dialog.Panel>
+1 -1
View File
@@ -46,7 +46,7 @@ export default function NewMachine(data: NewMachineProps) {
placeholder="Select a user" placeholder="Select a user"
> >
{data.users.map((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> </Select>
</Dialog.Panel> </Dialog.Panel>
+2 -2
View File
@@ -59,10 +59,10 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
)} )}
<TableList.Item <TableList.Item
className={cn( className={cn(
'rounded-b-xl focus-within:ring', 'rounded-b-xl focus-within:ring-3',
tag.length > 0 && tag.length > 0 &&
(!tag.startsWith('tag:') || tags.includes(tag)) && (!tag.startsWith('tag:') || tags.includes(tag)) &&
'ring ring-red-500 ring-opacity-50', 'ring-3 ring-red-500 ring-opacity-50',
)} )}
> >
<Input <Input
+2 -2
View File
@@ -14,7 +14,7 @@ export async function machineAction({
); );
const formData = await request.formData(); const formData = await request.formData();
const apiKey = session.get('api_key')!; const apiKey = session.api_key;
const action = formData.get('action_id')?.toString(); const action = formData.get('action_id')?.toString();
if (!action) { if (!action) {
@@ -55,7 +55,7 @@ export async function machineAction({
} }
if ( if (
node.user.providerId?.split('/').pop() !== session.get('user')!.subject && node.user.providerId?.split('/').pop() !== session.user.subject &&
!check !check
) { ) {
throw data('You do not have permission to act on this machine', { throw data('You do not have permission to act on this machine', {
+34 -24
View File
@@ -2,7 +2,6 @@ import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { Link as RemixLink, useLoaderData } from 'react-router'; import { Link as RemixLink, useLoaderData } from 'react-router';
import { mapTag } from 'yaml/util';
import Attribute from '~/components/Attribute'; import Attribute from '~/components/Attribute';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Card from '~/components/Card'; import Card from '~/components/Card';
@@ -40,9 +39,9 @@ export async function loader({
const [machine, { users }] = await Promise.all([ const [machine, { users }] = await Promise.all([
context.client.get<{ node: Machine }>( context.client.get<{ node: Machine }>(
`v1/node/${params.id}`, `v1/node/${params.id}`,
session.get('api_key')!, session.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 lookup = await context.agents?.lookup([machine.node.nodeKey]);
@@ -78,7 +77,7 @@ export default function Page() {
return ( return (
<div> <div>
<p className="mb-8 text-md"> <p className="mb-8 text-md">
<RemixLink to="/machines" className="font-medium"> <RemixLink className="font-medium" to="/machines">
All Machines All Machines
</RemixLink> </RemixLink>
<span className="mx-2">/</span> <span className="mx-2">/</span>
@@ -92,9 +91,9 @@ export default function Page() {
> >
<span className="flex items-baseline gap-x-4 text-sm"> <span className="flex items-baseline gap-x-4 text-sm">
<h1 className="text-2xl font-medium">{node.givenName}</h1> <h1 className="text-2xl font-medium">{node.givenName}</h1>
<StatusCircle isOnline={node.online} className="w-4 h-4" /> <StatusCircle className="w-4 h-4" isOnline={node.online} />
</span> </span>
<MenuOptions isFullButton node={node} users={users} magic={magic} /> <MenuOptions isFullButton magic={magic} node={node} users={users} />
</div> </div>
<div className="flex gap-1 mb-4"> <div className="flex gap-1 mb-4">
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4"> <div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
@@ -109,7 +108,10 @@ export default function Page() {
</span> </span>
<div className="flex items-center gap-x-2.5 mt-1"> <div className="flex items-center gap-x-2.5 mt-1">
<UserCircle /> <UserCircle />
{node.user.name} {node.user.name ||
node.user.displayName ||
node.user.email ||
node.user.id}
</div> </div>
</div> </div>
<div className="p-2 pl-4"> <div className="p-2 pl-4">
@@ -124,14 +126,14 @@ export default function Page() {
</div> </div>
</div> </div>
</div> </div>
<Routes node={node} isOpen={showRouting} setIsOpen={setShowRouting} /> <Routes isOpen={showRouting} node={node} setIsOpen={setShowRouting} />
<h2 className="text-xl font-medium mt-8">Subnets & Routing</h2> <h2 className="text-xl font-medium mt-8">Subnets & Routing</h2>
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<p> <p>
Subnets let you expose physical network routes onto Tailscale.{' '} Subnets let you expose physical network routes onto Tailscale.{' '}
<Link <Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation" name="Tailscale Subnets Documentation"
to="https://tailscale.com/kb/1019/subnets"
> >
Learn More Learn More
</Link> </Link>
@@ -139,11 +141,11 @@ export default function Page() {
<Button onPress={() => setShowRouting(true)}>Review</Button> <Button onPress={() => setShowRouting(true)}>Review</Button>
</div> </div>
<Card <Card
variant="flat"
className={cn( className={cn(
'w-full max-w-full grid sm:grid-cols-2', 'w-full max-w-full grid sm:grid-cols-2',
'md:grid-cols-4 gap-8 mr-2 text-sm mb-8', 'md:grid-cols-4 gap-8 mr-2 text-sm mb-8',
)} )}
variant="flat"
> >
<div> <div>
<span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1"> <span className="text-headplane-600 dark:text-headplane-300 flex items-center gap-x-1">
@@ -167,11 +169,11 @@ export default function Page() {
)} )}
</div> </div>
<Button <Button
onPress={() => setShowRouting(true)}
className={cn( className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5', 'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400', 'text-blue-500 dark:text-blue-400',
)} )}
onPress={() => setShowRouting(true)}
> >
Edit Edit
</Button> </Button>
@@ -199,11 +201,11 @@ export default function Page() {
)} )}
</div> </div>
<Button <Button
onPress={() => setShowRouting(true)}
className={cn( className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5', 'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400', 'text-blue-500 dark:text-blue-400',
)} )}
onPress={() => setShowRouting(true)}
> >
Edit Edit
</Button> </Button>
@@ -234,11 +236,11 @@ export default function Page() {
)} )}
</div> </div>
<Button <Button
onPress={() => setShowRouting(true)}
className={cn( className={cn(
'px-1.5 py-0.5 rounded-md mt-1.5', 'px-1.5 py-0.5 rounded-md mt-1.5',
'text-blue-500 dark:text-blue-400', 'text-blue-500 dark:text-blue-400',
)} )}
onPress={() => setShowRouting(true)}
> >
Edit Edit
</Button> </Button>
@@ -250,15 +252,23 @@ export default function Page() {
issues. issues.
</p> </p>
<Card <Card
variant="flat"
className="w-full max-w-full grid grid-cols-1 lg:grid-cols-2 gap-y-2 sm:gap-x-12" 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"> <div className="flex flex-col gap-1">
<Attribute name="Creator" value={node.user.name} /> <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="Machine name" value={node.givenName} />
<Attribute <Attribute
tooltip="OS hostname is published by the machines operating system and is used as the default name for the machine."
name="OS hostname" 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} value={node.name}
/> />
{stats ? ( {stats ? (
@@ -268,14 +278,14 @@ export default function Page() {
</> </>
) : undefined} ) : undefined}
<Attribute <Attribute
tooltip="ID for this machine. Used in the Headscale API."
name="ID" name="ID"
tooltip="ID for this machine. Used in the Headscale API."
value={node.id} value={node.id}
/> />
<Attribute <Attribute
isCopyable isCopyable
tooltip="Public key which uniquely identifies this machine."
name="Node key" name="Node key"
tooltip="Public key which uniquely identifies this machine."
value={node.nodeKey} value={node.nodeKey}
/> />
<Attribute <Attribute
@@ -312,27 +322,27 @@ export default function Page() {
</p> </p>
<Attribute <Attribute
isCopyable isCopyable
tooltip="This machines IPv4 address within your tailnet (your private Tailscale network)."
name="Tailscale IPv4" name="Tailscale IPv4"
tooltip="This machines IPv4 address within your tailnet (your private Tailscale network)."
value={getIpv4Address(node.ipAddresses)} value={getIpv4Address(node.ipAddresses)}
/> />
<Attribute <Attribute
isCopyable isCopyable
tooltip="This machines IPv6 address within your tailnet (your private Tailscale network). Connections within your tailnet support IPv6 even if your ISP does not."
name="Tailscale IPv6" 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)} value={getIpv6Address(node.ipAddresses)}
/> />
<Attribute <Attribute
isCopyable isCopyable
tooltip="Users of your tailnet can use this DNS short name to access this machine."
name="Short domain" name="Short domain"
tooltip="Users of your tailnet can use this DNS short name to access this machine."
value={node.givenName} value={node.givenName}
/> />
{magic ? ( {magic ? (
<Attribute <Attribute
isCopyable isCopyable
tooltip="Users of your tailnet can use this DNS name to access this machine."
name="Full domain" name="Full domain"
tooltip="Users of your tailnet can use this DNS name to access this machine."
value={`${node.givenName}.${magic}`} value={`${node.givenName}.${magic}`}
/> />
) : undefined} ) : undefined}
@@ -342,13 +352,13 @@ export default function Page() {
Client Connectivity Client Connectivity
</p> </p>
<Attribute <Attribute
tooltip="Whether the machine is behind a difficult NAT that varies the machines IP address depending on the destination."
name="Varies" 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'} value={stats.NetInfo?.MappingVariesByDestIP ? 'Yes' : 'No'}
/> />
<Attribute <Attribute
tooltip="Whether the machine needs to traverse NATs with hairpinning."
name="Hairpinning" name="Hairpinning"
tooltip="Whether the machine needs to traverse NATs with hairpinning."
value={stats.NetInfo?.HairPinning ? 'Yes' : 'No'} value={stats.NetInfo?.HairPinning ? 'Yes' : 'No'}
/> />
<Attribute <Attribute
+12 -15
View File
@@ -1,4 +1,4 @@
import { InfoIcon } from '@primer/octicons-react'; import { Info } from 'lucide-react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router'; import { useLoaderData } from 'react-router';
import Code from '~/components/Code'; import Code from '~/components/Code';
@@ -18,7 +18,7 @@ export async function loader({
context, context,
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const user = session.get('user'); const user = session.user;
if (!user) { if (!user) {
throw new Error('Missing user session. Please log in again.'); throw new Error('Missing user session. Please log in again.');
} }
@@ -41,11 +41,8 @@ export async function loader({
); );
const [{ nodes }, { users }] = await Promise.all([ const [{ nodes }, { users }] = await Promise.all([
context.client.get<{ nodes: Machine[] }>( context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
'v1/node', context.client.get<{ users: User[] }>('v1/user', session.api_key),
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]); ]);
let magic: string | undefined; let magic: string | undefined;
@@ -90,18 +87,18 @@ export default function Page() {
<p> <p>
Manage the devices connected to your Tailnet.{' '} Manage the devices connected to your Tailnet.{' '}
<Link <Link
to="https://tailscale.com/kb/1372/manage-devices"
name="Tailscale Manage Devices Documentation" name="Tailscale Manage Devices Documentation"
to="https://tailscale.com/kb/1372/manage-devices"
> >
Learn more Learn more
</Link> </Link>
</p> </p>
</div> </div>
<NewMachine <NewMachine
disabledKeys={data.preAuth ? [] : ['pre-auth']}
isDisabled={!data.writable}
server={data.publicServer ?? data.server} server={data.publicServer ?? data.server}
users={data.users} users={data.users}
isDisabled={!data.writable}
disabledKeys={data.preAuth ? [] : ['pre-auth']}
/> />
</div> </div>
<table className="table-auto w-full rounded-lg"> <table className="table-auto w-full rounded-lg">
@@ -113,7 +110,7 @@ export default function Page() {
<p className="uppercase text-xs font-bold">Addresses</p> <p className="uppercase text-xs font-bold">Addresses</p>
{data.magic ? ( {data.magic ? (
<Tooltip> <Tooltip>
<InfoIcon className="w-4 h-4" /> <Info className="w-4 h-4" />
<Tooltip.Body className="font-normal"> <Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based on Since MagicDNS is enabled, you can access devices based on
their name and also at{' '} their name and also at{' '}
@@ -141,16 +138,16 @@ export default function Page() {
> >
{data.populatedNodes.map((machine) => ( {data.populatedNodes.map((machine) => (
<MachineRow <MachineRow
key={machine.id}
node={machine}
users={data.users}
magic={data.magic}
isAgent={data.agent ? data.agent === machine.nodeKey : undefined} isAgent={data.agent ? data.agent === machine.nodeKey : undefined}
isDisabled={ isDisabled={
data.writable data.writable
? false // If the user has write permissions, they can edit all machines ? false // If the user has write permissions, they can edit all machines
: machine.user.providerId?.split('/').pop() !== data.subject : machine.user.providerId?.split('/').pop() !== data.subject
} }
key={machine.id}
magic={data.magic}
node={machine}
users={data.users}
/> />
))} ))}
</tbody> </tbody>
+1 -1
View File
@@ -20,7 +20,7 @@ export async function authKeysAction({
} }
const formData = await request.formData(); const formData = await request.formData();
const apiKey = session.get('api_key')!; const apiKey = session.api_key;
const action = formData.get('action_id')?.toString(); const action = formData.get('action_id')?.toString();
if (!action) { if (!action) {
throw data('Missing `action_id` in the form data.', { throw data('Missing `action_id` in the form data.', {
@@ -17,8 +17,12 @@ export default function AuthKeyRow({ authKey, user, url }: Props) {
return ( return (
<div className="w-full"> <div className="w-full">
<Attribute name="Key" value={authKey.key} isCopyable /> <Attribute isCopyable name="Key" value={authKey.key} />
<Attribute name="User" value={user.name} isCopyable /> <Attribute
isCopyable
name="User"
value={user.name || user.displayName || user.email || user.id}
/>
<Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} /> <Attribute name="Reusable" value={authKey.reusable ? 'Yes' : 'No'} />
<Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} /> <Attribute name="Ephemeral" value={authKey.ephemeral ? 'Yes' : 'No'} />
<Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} /> <Attribute name="Used" value={authKey.used ? 'Yes' : 'No'} />
@@ -30,13 +34,12 @@ export default function AuthKeyRow({ authKey, user, url }: Props) {
<Code className="text-sm"> <Code className="text-sm">
tailscale up --login-server={url} --authkey {authKey.key} tailscale up --login-server={url} --authkey {authKey.key}
</Code> </Code>
<div suppressHydrationWarning className="flex gap-4 items-center"> <div className="flex gap-4 items-center" suppressHydrationWarning>
{(authKey.used && !authKey.reusable) || {(authKey.used && !authKey.reusable) ||
new Date(authKey.expiration) < new Date() ? undefined : ( new Date(authKey.expiration) < new Date() ? undefined : (
<ExpireAuthKey authKey={authKey} user={user} /> <ExpireAuthKey authKey={authKey} user={user} />
)} )}
<Button <Button
variant="light"
className="my-4" className="my-4"
onPress={async () => { onPress={async () => {
await navigator.clipboard.writeText( await navigator.clipboard.writeText(
@@ -45,6 +48,7 @@ export default function AuthKeyRow({ authKey, user, url }: Props) {
toast('Copied command to clipboard'); toast('Copied command to clipboard');
}} }}
variant="light"
> >
Copy Tailscale Command Copy Tailscale Command
</Button> </Button>
@@ -36,7 +36,7 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
}} }}
> >
{data.users.map((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> </Select>
<NumberInput <NumberInput
+12 -11
View File
@@ -1,8 +1,7 @@
import { FileKey2 } from 'lucide-react'; import { FileKey2 } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router'; import { Link as RemixLink, useLoaderData } from 'react-router';
import { Link as RemixLink } from 'react-router';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Link from '~/components/Link'; import Link from '~/components/Link';
import Notice from '~/components/Notice'; import Notice from '~/components/Notice';
@@ -23,7 +22,7 @@ export async function loader({
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const { users } = await context.client.get<{ users: User[] }>( const { users } = await context.client.get<{ users: User[] }>(
'v1/user', 'v1/user',
session.get('api_key')!, session.api_key,
); );
const preAuthKeys = await Promise.all( const preAuthKeys = await Promise.all(
@@ -36,7 +35,7 @@ export async function loader({
try { try {
const { preAuthKeys } = await context.client.get<{ const { preAuthKeys } = await context.client.get<{
preAuthKeys: PreAuthKey[]; preAuthKeys: PreAuthKey[];
}>(`v1/preauthkey?${qp.toString()}`, session.get('api_key')!); }>(`v1/preauthkey?${qp.toString()}`, session.api_key);
return { return {
success: true, success: true,
user, user,
@@ -139,13 +138,15 @@ export default function Page() {
return key.reusable; return key.reusable;
} }
return false;
}); });
}, [keys, selectedUser, status]); }, [keys, selectedUser, status]);
return ( return (
<div className="flex flex-col md:w-2/3"> <div className="flex flex-col md:w-2/3">
<p className="mb-8 text-md"> <p className="mb-8 text-md">
<RemixLink to="/settings" className="font-medium"> <RemixLink className="font-medium" to="/settings">
Settings Settings
</RemixLink> </RemixLink>
<span className="mx-2">/</span> Pre-Auth Keys <span className="mx-2">/</span> Pre-Auth Keys
@@ -176,8 +177,8 @@ export default function Page() {
devices to your Tailnet. To learn more about using pre-authentication devices to your Tailnet. To learn more about using pre-authentication
keys, visit the{' '} keys, visit the{' '}
<Link <Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation" name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
> >
Tailscale documentation Tailscale documentation
</Link> </Link>
@@ -185,31 +186,31 @@ export default function Page() {
<AddAuthKey users={users} /> <AddAuthKey users={users} />
<div className="flex items-center gap-4 mt-4"> <div className="flex items-center gap-4 mt-4">
<Select <Select
label="User"
placeholder="Select a user"
className="w-full" className="w-full"
defaultSelectedKey="__headplane_all" defaultSelectedKey="__headplane_all"
isDisabled={isDisabled} isDisabled={isDisabled}
label="User"
onSelectionChange={(value) => onSelectionChange={(value) =>
setSelectedUser(value?.toString() ?? '') setSelectedUser(value?.toString() ?? '')
} }
placeholder="Select a user"
> >
{[ {[
<Select.Item key="__headplane_all">All</Select.Item>, <Select.Item key="__headplane_all">All</Select.Item>,
...keys.map(({ user }) => ( ...keys.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> </Select>
<Select <Select
label="Status"
placeholder="Select a status"
className="w-full" className="w-full"
defaultSelectedKey="active" defaultSelectedKey="active"
isDisabled={isDisabled} isDisabled={isDisabled}
label="Status"
onSelectionChange={(value) => onSelectionChange={(value) =>
setStatus((value?.toString() ?? 'active') as Status) setStatus((value?.toString() ?? 'active') as Status)
} }
placeholder="Select a status"
> >
<Select.Item key="all">All</Select.Item> <Select.Item key="all">All</Select.Item>
<Select.Item key="active">Active</Select.Item> <Select.Item key="active">Active</Select.Item>
-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>
);
}
+6 -6
View File
@@ -1,4 +1,4 @@
import { ArrowRightIcon } from '@primer/octicons-react'; import { ArrowRight } from 'lucide-react';
import { import {
LoaderFunctionArgs, LoaderFunctionArgs,
Link as RemixLink, Link as RemixLink,
@@ -18,7 +18,7 @@ export default function Page() {
const { config, oidc } = useLoaderData<typeof loader>(); const { config, oidc } = useLoaderData<typeof loader>();
return ( 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"> <div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">Settings</h1> <h1 className="text-2xl font-medium mb-4">Settings</h1>
<p> <p>
@@ -34,8 +34,8 @@ export default function Page() {
add devices to your Tailnet. To learn more about using add devices to your Tailnet. To learn more about using
pre-authentication keys, visit the{' '} pre-authentication keys, visit the{' '}
<Link <Link
to="https://tailscale.com/kb/1085/auth-keys/"
name="Tailscale Auth Keys documentation" name="Tailscale Auth Keys documentation"
to="https://tailscale.com/kb/1085/auth-keys/"
> >
Tailscale documentation Tailscale documentation
</Link> </Link>
@@ -44,7 +44,7 @@ export default function Page() {
<RemixLink to="/settings/auth-keys"> <RemixLink to="/settings/auth-keys">
<div className="text-lg font-medium flex items-center"> <div className="text-lg font-medium flex items-center">
Manage Auth Keys Manage Auth Keys
<ArrowRightIcon className="w-5 h-5 ml-2" /> <ArrowRight className="w-5 h-5 ml-2" />
</div> </div>
</RemixLink> </RemixLink>
{config && oidc ? ( {config && oidc ? (
@@ -60,8 +60,8 @@ export default function Page() {
groups and Headplane will also respect these settings when groups and Headplane will also respect these settings when
authenticating.{' '} authenticating.{' '}
<Link <Link
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
name="Headscale OIDC documentation" name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
> >
Learn More Learn More
</Link> </Link>
@@ -70,7 +70,7 @@ export default function Page() {
<RemixLink to="/settings/restrictions"> <RemixLink to="/settings/restrictions">
<div className="text-lg font-medium flex items-center"> <div className="text-lg font-medium flex items-center">
Manage Restrictions Manage Restrictions
<ArrowRightIcon className="w-5 h-5 ml-2" /> <ArrowRight className="w-5 h-5 ml-2" />
</div> </div>
</RemixLink> </RemixLink>
</> </>
@@ -52,7 +52,7 @@ export default function Page() {
const isDisabled = writable ? !access : true; const isDisabled = writable ? !access : true;
return ( return (
<div className="flex flex-col gap-4 max-w-screen-lg"> <div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<p className="mb-4 text-md"> <p className="mb-4 text-md">
<RemixLink to="/settings" className="font-medium"> <RemixLink to="/settings" className="font-medium">
+316
View File
@@ -0,0 +1,316 @@
/** 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,
LinksFunction,
LoaderFunctionArgs,
ShouldRevalidateFunction,
useLoaderData,
useSubmit,
} from 'react-router';
import { ExternalScriptsHandle } from 'remix-utils/external-scripts';
import { LoadContext } from '~/server';
import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
import { Machine, PreAuthKey, User } from '~/types';
import { useLiveData } from '~/utils/live-data';
import UserPrompt from './user-prompt';
import XTerm from './xterm.client';
export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
};
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const origin = new URL(request.url).origin;
const assets = ['/wasm_exec.js', '/hp_ssh.wasm'];
const missing: string[] = [];
for (const file of assets) {
const res = await fetch(`${origin}${file}`, { method: 'HEAD' });
if (!res.ok) missing.push(file);
}
if (missing.length > 0) {
throw data('WebSSH is not configured in this build.', 405);
}
if (!context.agents?.agentID()) {
throw data(
'WebSSH is only available with the Headplane agent integration',
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 const links: LinksFunction = () => [
{
rel: 'preload',
href: '/hp_ssh.wasm',
as: 'fetch',
type: 'application/wasm',
crossOrigin: 'anonymous',
},
];
export const handle: ExternalScriptsHandle = {
scripts: [
{
src: '/wasm_exec.js',
crossOrigin: 'anonymous',
preload: true,
},
],
};
export default function Page() {
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('/hp_ssh.wasm'),
go.importObject,
).then((value) => {
go.run(value.instance);
const handle = TsWasmNet(ipnDetails, {
NotifyState: (state) => {
console.log('State changed:', state);
if (state === 'Running') {
setIpn(handle);
}
},
NotifyNetMap: (netmap) => {
// Only set NodeKey if it is not already set and then
// also dispatch that to the backend to track the
// ephemeral node.
//
// We open an SSE connection to the backend
// so that when the connection is closed,
// the backend can delete the ephemeral node.
if (nodeKey === null) {
setNodeKey(netmap.NodeKey);
submit(
{
node_key: netmap.NodeKey,
auth_key: ipnDetails.PreAuthKey,
},
{ method: 'POST' },
);
}
},
NotifyBrowseToURL: (url) => {
console.log('Browse to URL:', url);
},
NotifyPanicRecover: (message) => {
console.error('Panic recover:', message);
},
});
handle.Start();
});
}, []);
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;
}
+226
View File
@@ -0,0 +1,226 @@
import { ClipboardAddon } from '@xterm/addon-clipboard';
import { FitAddon } from '@xterm/addon-fit';
import { Unicode11Addon } from '@xterm/addon-unicode11';
import { WebLinksAddon } from '@xterm/addon-web-links';
import * as xterm from '@xterm/xterm';
import { Loader2 } from 'lucide-react';
import { useEffect, useRef, useState } from 'react';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
import toast from '~/utils/toast';
import '@xterm/xterm/css/xterm.css';
interface XTermProps {
ipn: TsWasmNet;
username: string;
hostname: string;
}
// Go's WASM -> JS crosses realms so we might have to normalize the data under
// certain conditions. This also enforces bytes instead of strings being sent.
function normU8(data: unknown) {
if (data instanceof Uint8Array) {
return data;
}
if (data && typeof data === 'object') {
const any = data as {
buffer?: ArrayBufferLike;
byteOffset?: number;
byteLength?: number;
};
if (
any.buffer instanceof ArrayBuffer &&
typeof any.byteLength === 'number'
) {
return new Uint8Array(
any.buffer.slice(
any.byteOffset ?? 0,
(any.byteOffset ?? 0) + any.byteLength,
),
);
}
}
throw new Error('Data is not a Uint8Array or ArrayBuffer-like object');
}
export default function XTerm({ ipn, username, hostname }: XTermProps) {
const { pause } = useLiveData();
const genRef = useRef(0);
const termRef = useRef<xterm.Terminal>(null);
const roRef = useRef<ResizeObserver>(null);
const inputRef = useRef<(input: Uint8Array) => void>(null);
const sshRef = useRef<SSHSession>(null);
const divRef = useRef<HTMLDivElement>(null);
const [isResizing, setIsResizing] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
pause();
});
useEffect(() => {
if (!divRef.current) {
return;
}
const currentGen = ++genRef.current;
const term = new xterm.Terminal({
allowProposedApi: true,
cursorBlink: true,
convertEol: true,
fontSize: 14,
});
const fit = new FitAddon();
term.loadAddon(fit);
term.loadAddon(new Unicode11Addon());
term.loadAddon(new ClipboardAddon());
term.loadAddon(
new WebLinksAddon((event, uri) => {
event.view?.open(uri, '_blank', 'noopener noreferrer');
}),
);
term.unicode.activeVersion = '11';
termRef.current = term;
term.open(divRef.current!);
fit.fit();
term.focus();
const session = ipn.OpenSSH(hostname, username, {
rows: term.rows,
cols: term.cols,
onStdout: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn('Stale terminal instance, ignoring stdout');
return;
}
const text = normU8(data);
term.write(text);
},
onStderr: (data) => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn('Stale terminal instance, ignoring stderr');
return;
}
const text = normU8(data);
term.write(text);
const str = new TextDecoder().decode(text);
setError(str);
},
onStdin: (func) => {
inputRef.current = func;
},
onConnect: () => {
if (currentGen !== genRef.current) {
console.warn('Stale terminal instance, ignoring onConnect');
return;
}
setIsLoading(false);
},
onDisconnect: () => {
if (currentGen !== genRef.current) {
console.warn('Stale terminal instance, ignoring onDisconnect');
return;
}
roRef.current?.disconnect();
term.dispose();
termRef.current = null;
inputRef.current = null;
sshRef.current = null;
setIsLoading(false);
},
});
sshRef.current = session;
const enc = new TextEncoder();
term.onData((data) => {
if (currentGen !== genRef.current) {
console.warn('Stale terminal instance, ignoring onData');
return;
}
const bytes = enc.encode(data);
inputRef.current?.(bytes);
});
const ro = new ResizeObserver(() => {
if (currentGen !== genRef.current || term !== termRef.current) {
console.warn('Stale terminal instance, ignoring resize');
return;
}
setIsResizing(true);
fit.fit();
sshRef.current?.Resize(term.cols, term.rows);
setTimeout(() => setIsResizing(false), 100);
});
roRef.current = ro;
ro.observe(divRef.current!);
return () => {
++genRef.current;
roRef.current?.disconnect();
roRef.current = null;
sshRef.current?.Close();
sshRef.current = null;
term.dispose();
if (termRef.current === term) {
termRef.current = null;
}
inputRef.current = null;
};
}, [ipn, username, hostname]);
return (
<>
{isLoading ? (
<div className="absolute w-screen z-50 mx-auto h-screen flex items-center justify-center">
<Loader2 className="animate-spin size-10 text-headplane-50" />
</div>
) : undefined}
<div
className={cn('w-full h-full', isLoading ? 'opacity-0' : 'opacity-100')}
ref={divRef}
/>
{termRef.current && isResizing ? (
<div
className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2',
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
)}
>
{termRef.current.cols}x{termRef.current.rows}
</div>
) : undefined}
{error !== null ? (
<div
className={cn(
'absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center',
'px-4 py-2 bg-headplane-800 text-white rounded-full shadow z-50',
)}
>
Failed to connect to SSH session
{error}
</div>
) : undefined}
</>
);
}
+2 -2
View File
@@ -26,14 +26,14 @@ export default function UserRow({ user, role }: UserRowProps) {
{user.profilePicUrl ? ( {user.profilePicUrl ? (
<img <img
src={user.profilePicUrl} src={user.profilePicUrl}
alt={user.name} alt={user.name || user.displayName}
className="w-10 h-10 rounded-full" className="w-10 h-10 rounded-full"
/> />
) : ( ) : (
<CircleUser className="w-10 h-10" /> <CircleUser className="w-10 h-10" />
)} )}
<div className="ml-4"> <div className="ml-4">
<p className={cn('font-semibold leading-snug')}>{user.name}</p> <p className={cn('font-semibold leading-snug')}>{user.name || user.displayName}</p>
<p className="text-sm opacity-50">{user.email}</p> <p className="text-sm opacity-50">{user.email}</p>
</div> </div>
</div> </div>
+1 -2
View File
@@ -8,8 +8,7 @@ interface DeleteProps {
} }
export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) { export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
const name = const name = user.name || user.displayName;
(user.displayName?.length ?? 0) > 0 ? user.displayName : user.name;
return ( return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}> <Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
+1 -1
View File
@@ -21,7 +21,7 @@ export default function ReassignUser({
<Dialog.Panel <Dialog.Panel
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'} variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
> >
<Dialog.Title>Change role for {user.name}?</Dialog.Title> <Dialog.Title>Change role for {user.name || user.displayName}?</Dialog.Title>
<Dialog.Text className="mb-6"> <Dialog.Text className="mb-6">
Most roles are carried straight from Tailscale. However, keep in mind Most roles are carried straight from Tailscale. However, keep in mind
that I have not fully implemented permissions yet and some things may that I have not fully implemented permissions yet and some things may
+2 -2
View File
@@ -13,9 +13,9 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
return ( return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}> <Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel> <Dialog.Panel>
<Dialog.Title>Rename {user.name}?</Dialog.Title> <Dialog.Title>Rename {user.name || user.displayName}?</Dialog.Title>
<Dialog.Text className="mb-6"> <Dialog.Text className="mb-6">
Enter a new username for {user.name}. Changing a username will not Enter a new username for {user.name || user.displayName}. Changing a username will not
update any ACL policies that may refer to this user by their old update any ACL policies that may refer to this user by their old
username. username.
</Dialog.Text> </Dialog.Text>
+13 -6
View File
@@ -1,16 +1,23 @@
import { eq } from 'drizzle-orm';
import { LoaderFunctionArgs, redirect } from 'react-router'; import { LoaderFunctionArgs, redirect } from 'react-router';
import { LoadContext } from '~/server'; import { LoadContext } from '~/server';
import { users } from '~/server/db/schema';
export async function loader({ export async function loader({
request, request,
context, context,
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); try {
const user = session.get('user'); const { user } = await context.sessions.auth(request);
if (!user) { await context.db
.update(users)
.set({
onboarded: true,
})
.where(eq(users.sub, user.subject));
return redirect('/machines');
} catch {
return redirect('/login'); return redirect('/login');
} }
context.sessions.overrideOnboarding(user.subject, true);
return redirect('/machines');
} }
+33 -47
View File
@@ -1,15 +1,7 @@
import { Icon } from '@iconify/react';
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { GrApple } from 'react-icons/gr'; import { LoaderFunctionArgs, NavLink, useLoaderData } from 'react-router';
import { ImFinder } from 'react-icons/im';
import { MdAndroid } from 'react-icons/md';
import { PiTerminalFill, PiWindowsLogoFill } from 'react-icons/pi';
import {
LoaderFunctionArgs,
NavLink,
redirect,
useLoaderData,
} from 'react-router';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Card from '~/components/Card'; import Card from '~/components/Card';
import Link from '~/components/Link'; import Link from '~/components/Link';
@@ -27,10 +19,6 @@ export async function loader({
context, context,
}: LoaderFunctionArgs<LoadContext>) { }: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const user = session.get('user');
if (!user) {
return redirect('/login');
}
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android // Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
// We need to convert this to a known value to return it to the client so we can // We need to convert this to a known value to return it to the client so we can
@@ -60,11 +48,11 @@ export async function loader({
break; break;
} }
let firstMachine: Machine | undefined = undefined; let firstMachine: Machine | undefined;
try { try {
const { nodes } = await context.client.get<{ nodes: Machine[] }>( const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node', 'v1/node',
session.get('api_key')!, session.api_key,
); );
const node = nodes.find((n) => { const node = nodes.find((n) => {
@@ -79,12 +67,7 @@ export async function loader({
return false; return false;
} }
const sessionUser = session.get('user'); if (subject !== session.user.subject) {
if (!sessionUser) {
return false;
}
if (subject !== sessionUser.subject) {
return false; return false;
} }
@@ -98,7 +81,7 @@ export async function loader({
} }
return { return {
user, user: session.user,
osValue, osValue,
firstMachine, firstMachine,
}; };
@@ -126,7 +109,7 @@ export default function Page() {
return ( return (
<div className="fixed w-full h-screen flex items-center px-4"> <div className="fixed w-full h-screen flex items-center px-4">
<div className="w-fit mx-auto grid grid-cols-1 md:grid-cols-2 gap-4 mb-24"> <div className="w-fit mx-auto grid grid-cols-1 md:grid-cols-2 gap-4 mb-24">
<Card variant="flat" className="max-w-lg"> <Card className="max-w-lg" variant="flat">
<Card.Title className="mb-8"> <Card.Title className="mb-8">
Welcome! Welcome!
<br /> <br />
@@ -138,15 +121,15 @@ export default function Page() {
</Card.Text> </Card.Text>
<Options <Options
className="my-4"
defaultSelectedKey={osValue} defaultSelectedKey={osValue}
label="Download Selector" label="Download Selector"
className="my-4"
> >
<Options.Item <Options.Item
key="linux" key="linux"
title={ title={
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<PiTerminalFill className="ml-1 w-4" /> <Icon className="ml-1 w-4" icon="ion:terminal" />
<span>Linux</span> <span>Linux</span>
</div> </div>
} }
@@ -177,18 +160,18 @@ export default function Page() {
key="windows" key="windows"
title={ title={
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<PiWindowsLogoFill className="ml-1 w-4" /> <Icon className="ml-1 w-4" icon="mdi:microsoft" />
<span>Windows</span> <span>Windows</span>
</div> </div>
} }
> >
<a <a
href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
aria-label="Download for Windows" aria-label="Download for Windows"
target="_blank" href="https://pkgs.tailscale.com/stable/tailscale-setup-latest.exe"
rel="noreferrer" rel="noreferrer"
target="_blank"
> >
<Button variant="heavy" className="my-4 w-full"> <Button className="my-4 w-full" variant="heavy">
Download for Windows Download for Windows
</Button> </Button>
</a> </a>
@@ -200,18 +183,21 @@ export default function Page() {
key="macos" key="macos"
title={ title={
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<ImFinder className="ml-1 w-4" /> <Icon
className="ml-1 w-4"
icon="streamline-logos:mac-finder-logo-solid"
/>
<span>macOS</span> <span>macOS</span>
</div> </div>
} }
> >
<a <a
href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
aria-label="Download for macOS" aria-label="Download for macOS"
target="_blank" href="https://pkgs.tailscale.com/stable/Tailscale-latest-macos.pkg"
rel="noreferrer" rel="noreferrer"
target="_blank"
> >
<Button variant="heavy" className="my-4 w-full"> <Button className="my-4 w-full" variant="heavy">
Download for macOS Download for macOS
</Button> </Button>
</a> </a>
@@ -232,18 +218,18 @@ export default function Page() {
key="ios" key="ios"
title={ title={
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<GrApple className="ml-1 w-4" /> <Icon className="ml-1 w-4" icon="grommet-icons:apple" />
<span>iOS</span> <span>iOS</span>
</div> </div>
} }
> >
<a <a
href="https://apps.apple.com/us/app/tailscale/id1470499037"
aria-label="Download for iOS" aria-label="Download for iOS"
target="_blank" href="https://apps.apple.com/us/app/tailscale/id1470499037"
rel="noreferrer" rel="noreferrer"
target="_blank"
> >
<Button variant="heavy" className="my-4 w-full"> <Button className="my-4 w-full" variant="heavy">
Download for iOS Download for iOS
</Button> </Button>
</a> </a>
@@ -255,18 +241,18 @@ export default function Page() {
key="android" key="android"
title={ title={
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<MdAndroid className="ml-1 w-4" /> <Icon className="ml-1 w-4" icon="material-symbols:android" />
<span>Android</span> <span>Android</span>
</div> </div>
} }
> >
<a <a
href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
aria-label="Download for Android" aria-label="Download for Android"
target="_blank" href="https://play.google.com/store/apps/details?id=com.tailscale.ipn"
rel="noreferrer" rel="noreferrer"
target="_blank"
> >
<Button variant="heavy" className="my-4 w-full"> <Button className="my-4 w-full" variant="heavy">
Download for Android Download for Android
</Button> </Button>
</a> </a>
@@ -287,8 +273,8 @@ export default function Page() {
<div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4"> <div className="border border-headplane-100 dark:border-headplane-800 rounded-xl p-4">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<StatusCircle <StatusCircle
isOnline={firstMachine.online}
className="size-6 mt-3" className="size-6 mt-3"
isOnline={firstMachine.online}
/> />
<div> <div>
<p className="font-semibold leading-snug"> <p className="font-semibold leading-snug">
@@ -300,7 +286,7 @@ export default function Page() {
<div className="mt-6"> <div className="mt-6">
<p className="text-sm font-semibold">IP Addresses</p> <p className="text-sm font-semibold">IP Addresses</p>
{firstMachine.ipAddresses.map((ip) => ( {firstMachine.ipAddresses.map((ip) => (
<p key={ip} className="text-xs font-mono opacity-50"> <p className="text-xs font-mono opacity-50" key={ip}>
{ip} {ip}
</p> </p>
))} ))}
@@ -308,8 +294,8 @@ export default function Page() {
</div> </div>
</div> </div>
</div> </div>
<NavLink to="/"> <NavLink to="/onboarding/skip">
<Button variant="heavy" className="w-full"> <Button className="w-full" variant="heavy">
Continue Continue
</Button> </Button>
</NavLink> </NavLink>
@@ -335,7 +321,7 @@ export default function Page() {
</div> </div>
)} )}
</Card> </Card>
<NavLink to="/onboarding/skip" className="col-span-2 w-max mx-auto"> <NavLink className="col-span-2 w-max mx-auto" to="/onboarding/skip">
<Button className="flex items-center gap-1"> <Button className="flex items-center gap-1">
I already know what I'm doing I already know what I'm doing
<ArrowRight className="p-1" /> <ArrowRight className="p-1" />
+29 -31
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData, useSubmit } from 'react-router'; import { useLoaderData } from 'react-router';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import { Machine, User } from '~/types'; import { Machine, User } from '~/types';
@@ -32,11 +32,8 @@ export async function loader({
); );
const [machines, apiUsers] = await Promise.all([ const [machines, apiUsers] = await Promise.all([
context.client.get<{ nodes: Machine[] }>( context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
'v1/node', context.client.get<{ users: User[] }>('v1/user', session.api_key),
session.get('api_key')!,
),
context.client.get<{ users: User[] }>('v1/user', session.get('api_key')!),
]); ]);
const users = apiUsers.users.map((user) => ({ const users = apiUsers.users.map((user) => ({
@@ -44,30 +41,32 @@ export async function loader({
machines: machines.nodes.filter((machine) => machine.user.id === user.id), machines: machines.nodes.filter((machine) => machine.user.id === user.id),
})); }));
const roles = users const roles = await Promise.all(
.sort((a, b) => a.name.localeCompare(b.name)) users
.map((user) => { .sort((a, b) => a.name.localeCompare(b.name))
if (user.provider !== 'oidc') { .map(async (user) => {
return 'no-oidc'; if (user.provider !== 'oidc') {
} return 'no-oidc';
if (user.provider === 'oidc' && user.providerId) {
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = user.providerId.split('/').pop();
if (!subject) {
return 'invalid-oidc';
} }
const role = context.sessions.roleForSubject(subject); if (user.provider === 'oidc' && user.providerId) {
return role ?? 'no-role'; // For some reason, headscale makes providerID a url where the
} // last component is the subject, so we need to strip that out
const subject = user.providerId.split('/').pop();
if (!subject) {
return 'invalid-oidc';
}
// No role means the user is not registered in Headplane, but they const role = await context.sessions.roleForSubject(subject);
// are in Headscale. We also need to handle what happens if someone return role ?? 'no-role';
// logs into the UI and they don't have a Headscale setup. }
return 'no-role';
}); // No role means the user is not registered in Headplane, but they
// are in Headscale. We also need to handle what happens if someone
// logs into the UI and they don't have a Headscale setup.
return 'no-role';
}),
);
let magic: string | undefined; let magic: string | undefined;
if (context.hs.readable()) { if (context.hs.readable()) {
@@ -105,10 +104,9 @@ export default function Page() {
<> <>
<h1 className="text-2xl font-medium mb-1.5">Users</h1> <h1 className="text-2xl font-medium mb-1.5">Users</h1>
<p className="mb-8 text-md"> <p className="mb-8 text-md">
Manage the users in your network and their permissions. Tip: You can Manage the users in your network and their permissions.
drag machines between users to change ownership.
</p> </p>
<ManageBanner oidc={data.oidc} isDisabled={!data.writable} /> <ManageBanner isDisabled={!data.writable} oidc={data.oidc} />
<table className="table-auto w-full rounded-lg"> <table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300"> <thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5"> <tr className="text-left px-0.5">
@@ -129,8 +127,8 @@ export default function Page() {
.map((user) => ( .map((user) => (
<UserRow <UserRow
key={user.id} key={user.id}
user={user}
role={data.roles[users.indexOf(user)]} role={data.roles[users.indexOf(user)]}
user={user}
/> />
))} ))}
</tbody> </tbody>
+2 -3
View File
@@ -1,7 +1,6 @@
import { ActionFunctionArgs, Session, data } from 'react-router'; import { ActionFunctionArgs, data } from 'react-router';
import type { LoadContext } from '~/server'; import type { LoadContext } from '~/server';
import { Capabilities, Roles } from '~/server/web/roles'; import { Capabilities, Roles } from '~/server/web/roles';
import { AuthSession } from '~/server/web/sessions';
import { User } from '~/types'; import { User } from '~/types';
import { data400, data403 } from '~/utils/res'; import { data400, data403 } from '~/utils/res';
@@ -15,7 +14,7 @@ export async function userAction({
throw data403('You do not have permission to update users'); throw data403('You do not have permission to update users');
} }
const apiKey = session.get('api_key')!; const apiKey = session.api_key;
const formData = await request.formData(); const formData = await request.formData();
const action = formData.get('action_id')?.toString(); const action = formData.get('action_id')?.toString();
if (!action) { if (!action) {
+4
View File
@@ -7,6 +7,10 @@ many side-effects (in this case, importing a module may run code).
``` ```
server server
├── index.ts: Loads everything and starts the web server. ├── index.ts: Loads everything and starts the web server.
├── agent/
│ ├── dispatcher.ts: Serializes commands for the agent control fd (stdin).
│ ├── ssh.ts: Manages & multiplexes the active web SSH connections
│ ├── env.ts: Checks the environment variables for custom overrides.
├── config/ ├── config/
│ ├── integration/ │ ├── integration/
│ │ ├── abstract.ts: Defines the abstract class for integrations. │ │ ├── abstract.ts: Defines the abstract class for integrations.
+26 -30
View File
@@ -3,13 +3,18 @@ import { platform } from 'node:os';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { kill } from 'node:process'; import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import { Config, CoreV1Api, KubeConfig } from '@kubernetes/client-node'; import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
import { ApiClient } from '~/server/headscale/api-client'; import { ApiClient } from '~/server/headscale/api-client';
import log from '~/utils/log'; import log from '~/utils/log';
import { HeadplaneConfig } from '../schema'; import { HeadplaneConfig } from '../schema';
import { Integration } from './abstract'; import { Integration } from './abstract';
// TODO: Upgrade to the new CoreV1Api from @kubernetes/client-node // https://github.com/kubernetes-client/javascript/blob/055b83c6504dfd1b2a2d081efd974163c6cbb808/src/config.ts#L40
const svcRoot = '/var/run/secrets/kubernetes.io/serviceaccount';
const svcCaPath = `${svcRoot}/ca.crt`;
const svcTokenPath = `${svcRoot}/token`;
const svcNamespacePath = `${svcRoot}/namespace`;
type T = NonNullable<HeadplaneConfig['integration']>['kubernetes']; type T = NonNullable<HeadplaneConfig['integration']>['kubernetes'];
export default class KubernetesIntegration extends Integration<T> { export default class KubernetesIntegration extends Integration<T> {
private pid: number | undefined; private pid: number | undefined;
@@ -25,7 +30,6 @@ export default class KubernetesIntegration extends Integration<T> {
return false; return false;
} }
const svcRoot = Config.SERVICEACCOUNT_ROOT;
try { try {
log.debug('config', 'Checking Kubernetes service account at %s', svcRoot); log.debug('config', 'Checking Kubernetes service account at %s', svcRoot);
const files = await readdir(svcRoot); const files = await readdir(svcRoot);
@@ -35,11 +39,7 @@ export default class KubernetesIntegration extends Integration<T> {
} }
const mappedFiles = new Set(files.map((file) => join(svcRoot, file))); const mappedFiles = new Set(files.map((file) => join(svcRoot, file)));
const expectedFiles = [ const expectedFiles = [svcCaPath, svcTokenPath, svcNamespacePath];
Config.SERVICEACCOUNT_CA_PATH,
Config.SERVICEACCOUNT_TOKEN_PATH,
Config.SERVICEACCOUNT_NAMESPACE_PATH,
];
log.debug('config', 'Looking for %s', expectedFiles.join(', ')); log.debug('config', 'Looking for %s', expectedFiles.join(', '));
if (!expectedFiles.every((file) => mappedFiles.has(file))) { if (!expectedFiles.every((file) => mappedFiles.has(file))) {
@@ -52,10 +52,7 @@ export default class KubernetesIntegration extends Integration<T> {
} }
log.debug('config', 'Reading Kubernetes service account at %s', svcRoot); log.debug('config', 'Reading Kubernetes service account at %s', svcRoot);
const namespace = await readFile( const namespace = await readFile(svcNamespacePath, 'utf8');
Config.SERVICEACCOUNT_NAMESPACE_PATH,
'utf8',
);
// Some very ugly nesting but it's necessary // Some very ugly nesting but it's necessary
if (this.context.validate_manifest === false) { if (this.context.validate_manifest === false) {
@@ -99,36 +96,32 @@ export default class KubernetesIntegration extends Integration<T> {
const kCoreV1Api = kc.makeApiClient(CoreV1Api); const kCoreV1Api = kc.makeApiClient(CoreV1Api);
log.info( log.info('config', 'Checking pod %s in namespace %s', pod, namespace);
'config',
'Checking pod %s in namespace %s (%s)',
pod,
namespace,
kCoreV1Api.basePath,
);
log.debug('config', 'Reading pod info for %s', pod); log.debug('config', 'Reading pod info for %s', pod);
const { response, body } = await kCoreV1Api.readNamespacedPod( const body = await kCoreV1Api.readNamespacedPod({
pod, name: pod,
namespace, namespace,
); });
if (response.statusCode !== 200) { if (!body.spec) {
log.error( log.error(
'config', 'config',
'Failed to read pod info: http %d', 'Missing spec in pod info for %s/%s',
response.statusCode, pod,
namespace,
); );
return false; return false;
} }
log.debug('config', 'Got pod info: %o', body.spec); log.debug('config', 'Got pod info: %o', body.spec);
const shared = body.spec?.shareProcessNamespace; const shared = body.spec.shareProcessNamespace;
if (shared === undefined) { if (shared === undefined) {
log.error( log.error(
'config', 'config',
'Pod does not have spec.shareProcessNamespace set', 'Pod does not have spec.shareProcessNamespace set',
); );
return false; return false;
} }
@@ -137,6 +130,7 @@ export default class KubernetesIntegration extends Integration<T> {
'config', 'config',
'Pod has set but disabled spec.shareProcessNamespace', 'Pod has set but disabled spec.shareProcessNamespace',
); );
return false; return false;
} }
@@ -158,12 +152,14 @@ export default class KubernetesIntegration extends Integration<T> {
return; return;
} }
const path = join('/proc', dir, 'cmdline'); const path = join('/proc', dir, 'comm');
try { try {
log.debug('config', 'Reading %s', path); log.debug('config', 'Reading %s', path);
const data = await readFile(path, 'utf8'); const data = await readFile(path, 'utf8');
if (!data.includes('headscale') && !data.includes('serve')) { if (data.trim() !== 'headscale') {
throw new Error('Found PID without Headscale serve command'); throw new Error(
`Found PID with unexpected command: ${data.trim()}`,
);
} }
return pid; return pid;
+32 -7
View File
@@ -34,17 +34,19 @@ export default class ProcIntegration extends Integration<T> {
return; return;
} }
const path = join('/proc', dir, 'cmdline'); const path = join('/proc', dir, 'comm');
try { try {
log.debug('config', 'Reading %s', path); log.debug('config', 'Reading %s', path);
const data = await readFile(path, 'utf8'); const data = await readFile(path, 'utf8');
if (!data.includes('headscale') && !data.includes('serve')) { if (data.trim() !== 'headscale') {
throw new Error('Found PID without Headscale serve command'); throw new Error(
`Found PID with unexpected command: ${data.trim()}`,
);
} }
return pid; return pid;
} catch (error) { } catch (error) {
log.error('config', 'Failed to read %s: %s', path, error); log.debug('config', 'Failed to read %s: %s', path, error);
} }
}); });
@@ -59,14 +61,37 @@ export default class ProcIntegration extends Integration<T> {
log.debug('config', 'Found Headscale processes: %o', pids); log.debug('config', 'Found Headscale processes: %o', pids);
if (pids.length > 1) { if (pids.length > 1) {
log.error( log.warn(
'config', 'config',
'Found %d Headscale processes: %s', 'Found %d Headscale processes: %s',
pids.length, pids.length,
pids.join(', '), pids.join(', '),
); );
return false;
} log.debug('config', 'Checking if any of them have Parent PID = 1, assuming thats the correct PID');
const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/;
for (const pid of pids) {
const pidStatusPath = join('/proc', pid.toString(), 'status');
try {
log.debug('config', 'Reading %s', pidStatusPath);
const pidData = await readFile(pidStatusPath, 'utf8');
const ppidResult = pidData.match(ppidRegex);
if (ppidResult !== null) {
const potentialPPid = Number.parseInt(ppidResult[1], 10);
if (potentialPPid === 1) {
this.pid = pid;
log.info('config', 'Found potential Headscale process with PID: %d based on Parent PID = 1', this.pid);
return true;
}
}
} catch (error) {
log.error('config', 'Failed to read %s: %s', pidStatusPath, error);
}
}
return false;
}
if (pids.length === 0) { if (pids.length === 0) {
log.error('config', 'Could not find Headscale process'); log.error('config', 'Could not find Headscale process');
+119 -41
View File
@@ -1,5 +1,5 @@
import { constants, access, readFile } from 'node:fs/promises'; import { access, constants, readFile } from 'node:fs/promises';
import { env, exit } from 'node:process'; import { env } from 'node:process';
import { type } from 'arktype'; import { type } from 'arktype';
import { configDotenv } from 'dotenv'; import { configDotenv } from 'dotenv';
import { parseDocument } from 'yaml'; import { parseDocument } from 'yaml';
@@ -11,6 +11,28 @@ import {
partialHeadplaneConfig, partialHeadplaneConfig,
} from './schema'; } from './schema';
// Custom error for config issues
export class ConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConfigError';
}
}
/**
* Interpolate environment variables in a string
* Replaces ${VAR_NAME} patterns with the actual environment variable values
*/
export function interpolateEnvVars(str: string): string {
return str.replace(/\$\{([^}]+)\}/g, (_, varName) => {
const value = env[varName];
if (value === undefined) {
throw new ConfigError(`Environment variable "${varName}" not found`);
}
return value;
});
}
// loadConfig is a has a lifetime of the entire application and is // loadConfig is a has a lifetime of the entire application and is
// used to load the configuration for Headplane. It is called once. // used to load the configuration for Headplane. It is called once.
// //
@@ -18,54 +40,108 @@ import {
// But this may not be necessary as a use-case anyways // But this may not be necessary as a use-case anyways
export async function loadConfig({ loadEnv, path }: EnvOverrides) { export async function loadConfig({ loadEnv, path }: EnvOverrides) {
log.debug('config', 'Loading configuration file: %s', path); log.debug('config', 'Loading configuration file: %s', path);
const valid = await validateConfigPath(path); await validateConfigPath(path);
if (!valid) {
exit(1);
}
const data = await loadConfigFile(path); const data = await loadConfigFile(path);
if (!data) { if (!data) {
exit(1); throw new ConfigError('Failed to load configuration file');
} }
let config = validateConfig({ ...data, debug: log.debugEnabled }); let config = validateConfig({ ...data, debug: log.debugEnabled });
if (!config) {
exit(1);
}
if (!loadEnv) { if (!loadEnv) {
log.debug('config', 'Environment variable overrides are disabled'); log.debug('config', 'Environment variable overrides are disabled');
log.debug('config', 'This also disables the loading of a .env file'); log.debug('config', 'This also disables the loading of a .env file');
return config; const moddedConfig = await loadSecretsFromFiles(config);
log.debug('config', 'Loaded file-based secrets');
return moddedConfig;
} }
log.info('config', 'Loading a .env file (if available)'); log.info('config', 'Loading a .env file (if available)');
configDotenv({ override: true }); configDotenv({ override: true, quiet: true });
config = coalesceEnv(config); const merged = coalesceEnv(config);
if (!config) { if (merged) config = merged;
exit(1); if (config.headscale && typeof config.headscale.config_path === 'string') {
config.headscale.config_path = interpolateEnvVars(
config.headscale.config_path,
);
} }
return config; const moddedConfig = await loadSecretsFromFiles(config);
log.debug('config', 'Loaded file-based secrets');
return moddedConfig;
} }
export async function hp_loadConfig() { /**
// // OIDC Related Checks * Recursively walks the config object; for any key in the whitelist of secret path keys,
// if (config.oidc) { * reads that file and assigns its contents to the corresponding key
// if (!config.oidc.client_secret && !config.oidc.client_secret_path) { * without the suffix, then removes the "_path" property.
// log.error('CFGX', 'OIDC configuration is missing a secret, disabling'); */
// log.error( const SECRET_PATH_KEYS = [
// 'CFGX', 'pre_authkey_path',
// 'Please specify either `oidc.client_secret` or `oidc.client_secret_path`', 'client_secret_path',
// ); 'headscale_api_key_path',
// } 'cookie_secret_path',
// if (config.oidc?.strict_validation) { ] as const;
// const result = await testOidc(config.oidc);
// if (!result) { // For fast set hashing lookups, but we still need the array for typings
// log.error('CFGX', 'OIDC configuration failed validation, disabling'); const SECRET_PATH_KEY_SET = new Set<string>(SECRET_PATH_KEYS);
// }
// } type SecretPathKey = (typeof SECRET_PATH_KEYS)[number];
// } type StripPath<S extends string> = S extends `${infer T}_path` ? T : never;
type KeysToPromote<T> = Extract<keyof T & string, SecretPathKey>;
type MappedKeys<T> = StripPath<KeysToPromote<T>>;
type NonNullablized<T> = Omit<T, KeysToPromote<T> | MappedKeys<T>> & {
[K in MappedKeys<T>]-?: string;
};
type NestedNonNullablized<T> = T extends readonly (infer U)[]
? readonly NestedNonNullablized<U>[]
: T extends (infer U)[]
? NestedNonNullablized<U>[]
: T extends object
? {
[K in keyof NonNullablized<T>]: NestedNonNullablized<
NonNullablized<T>[K]
>;
}
: T;
async function loadSecretsFromFiles<T extends object>(
obj: T,
): Promise<NestedNonNullablized<T>> {
// Work with a Record so we can mutate/delete properties
const record = obj as Record<string, unknown>;
for (const key of Object.keys(record)) {
const val = record[key];
if (val && typeof val === 'object') {
// recurse into nested objects
record[key] = await loadSecretsFromFiles(val);
continue;
}
if (SECRET_PATH_KEY_SET.has(key) && typeof val === 'string') {
try {
const path = interpolateEnvVars(val);
const content = await readFile(path, 'utf8');
const secretKey = key.slice(0, -5); // drop '_path'
record[secretKey] = content.trim();
delete record[key];
log.debug('config', 'Loaded secret from %s → %s', val, secretKey);
} catch (err) {
if (err instanceof ConfigError) throw err;
log.error('config', 'Failed to read secret file %s: %s', val, err);
throw new ConfigError(`Failed to read secret file ${val}: ${err}`);
}
}
}
// Cast back to the original T so callers keep their precise type
return record as NestedNonNullablized<T>;
} }
async function validateConfigPath(path: string) { async function validateConfigPath(path: string) {
@@ -76,7 +152,9 @@ async function validateConfigPath(path: string) {
} catch (error) { } catch (error) {
log.error('config', 'Unable to read a configuration file at %s', path); log.error('config', 'Unable to read a configuration file at %s', path);
log.error('config', '%s', error); log.error('config', '%s', error);
return false; throw new ConfigError(
`Unable to read configuration file at ${path}: ${error}`,
);
} }
} }
@@ -91,7 +169,7 @@ async function loadConfigFile(path: string): Promise<unknown> {
log.error('config', ` - ${error.toString()}`); log.error('config', ` - ${error.toString()}`);
} }
return false; throw new ConfigError(`Cannot parse configuration file at ${path}`);
} }
if (configYaml.warnings.length > 0) { if (configYaml.warnings.length > 0) {
@@ -109,7 +187,7 @@ async function loadConfigFile(path: string): Promise<unknown> {
} catch (e) { } catch (e) {
log.error('config', 'Error reading configuration file at %s', path); log.error('config', 'Error reading configuration file at %s', path);
log.error('config', '%s', e); log.error('config', '%s', e);
return false; throw new ConfigError(`Error reading configuration file at ${path}: ${e}`);
} }
} }
@@ -117,14 +195,14 @@ export function validateConfig(config: unknown) {
log.debug('config', 'Validating Headplane configuration'); log.debug('config', 'Validating Headplane configuration');
const result = headplaneConfig(config); const result = headplaneConfig(config);
if (result instanceof type.errors) { if (result instanceof type.errors) {
log.error('config', 'Error validating Headplane configuration:'); const errorMessages = [];
for (const [number, error] of result.entries()) { for (const [number, error] of result.entries()) {
log.error('config', ` - (${number}): ${error.toString()}`); const errorMsg = error.toString();
log.error('config', ` - (${number}): ${errorMsg}`);
errorMessages.push(errorMsg);
} }
throw new ConfigError(errorMessages.join('\n'));
return;
} }
return result; return result;
} }
+116 -17
View File
@@ -19,8 +19,36 @@ const stringToBool = type('string | boolean').pipe((v) => {
const serverConfig = type({ const serverConfig = type({
host: 'string.ip', host: 'string.ip',
port: type('string | number.integer').pipe((v) => Number(v)), port: type('string | number.integer').pipe((v) => Number(v)),
cookie_secret: '32 <= string <= 32', data_path: 'string = "/var/lib/headplane/"',
cookie_secret: '(32 <= string <= 32)?',
cookie_secret_path: 'string?',
cookie_secure: stringToBool, cookie_secure: stringToBool,
})
.narrow((obj: Record<string, unknown>, ctx: any) => {
const hasVal = obj.cookie_secret != null && `${obj.cookie_secret}` !== '';
const hasPath =
obj.cookie_secret_path != null && obj.cookie_secret_path !== '';
if (hasVal && hasPath)
return ctx.reject(
`Only one of "cookie_secret" or "cookie_secret_path" may be set.`,
);
if (!hasVal && !hasPath)
return ctx.reject(
`Either "cookie_secret" or "cookie_secret_path" must be provided for cookie_secret.`,
);
return true;
})
.onDeepUndeclaredKey('reject');
const partialServerConfig = type({
host: 'string.ip?',
port: type('string | number.integer')
.pipe((v) => Number(v))
.optional(),
data_path: 'string = "/var/lib/headplane/"',
cookie_secret: '32 <= string <= 32?',
cookie_secret_path: 'string?',
cookie_secure: stringToBool.optional(),
}); });
const oidcConfig = type({ const oidcConfig = type({
@@ -33,9 +61,53 @@ const oidcConfig = type({
redirect_uri: 'string.url?', redirect_uri: 'string.url?',
user_storage_file: 'string = "/var/lib/headplane/users.json"', user_storage_file: 'string = "/var/lib/headplane/users.json"',
disable_api_key_login: stringToBool, disable_api_key_login: stringToBool,
headscale_api_key: 'string', headscale_api_key: 'string?',
headscale_api_key_path: 'string?',
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
strict_validation: stringToBool.default(true), strict_validation: stringToBool.default(true),
}).onDeepUndeclaredKey('reject'); scope: 'string = "openid email profile"',
extra_params: 'Record<string, string>?',
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
})
.narrow((obj: Record<string, unknown>, ctx: any) => {
const hasVal =
obj.headscale_api_key != null && `${obj.headscale_api_key}` !== '';
const hasPath =
obj.headscale_api_key_path != null && obj.headscale_api_key_path !== '';
if (hasVal && hasPath)
return ctx.reject(
`Only one of "headscale_api_key" or "headscale_api_key_path" may be set.`,
);
if (!hasVal && !hasPath)
return ctx.reject(
`Either "headscale_api_key" or "headscale_api_key_path" must be provided.`,
);
return true;
})
.onDeepUndeclaredKey('reject');
const partialOidcConfig = type({
issuer: 'string.url?',
client_id: 'string?',
client_secret: 'string?',
client_secret_path: 'string?',
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
redirect_uri: 'string.url?',
user_storage_file: 'string?',
disable_api_key_login: stringToBool.optional(),
headscale_api_key: 'string?',
headscale_api_key_path: 'string?',
profile_picture_source: '("oidc" | "gravatar")?',
strict_validation: stringToBool.default(true),
scope: 'string?',
extra_params: 'Record<string, string>?',
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
});
const headscaleConfig = type({ const headscaleConfig = type({
url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)), url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)),
@@ -46,26 +118,53 @@ const headscaleConfig = type({
dns_records_path: 'string?', dns_records_path: 'string?',
}).onDeepUndeclaredKey('reject'); }).onDeepUndeclaredKey('reject');
const partialHeadscaleConfig = type({
url: type('string.url')
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
.optional(),
tls_cert_path: 'string?',
public_url: 'string.url?',
config_path: 'string?',
config_strict: stringToBool.optional(),
dns_records_path: 'string?',
});
const agentConfig = type({ const agentConfig = type({
enabled: stringToBool.default(false), enabled: stringToBool.default(false),
host_name: 'string = "headplane-agent"', host_name: 'string = "headplane-agent"',
pre_authkey: 'string = ""', pre_authkey: 'string?',
pre_authkey_path: 'string?',
cache_ttl: 'number.integer = 180000',
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
executable_path: 'string = "/usr/libexec/headplane/agent"',
work_dir: 'string = "/var/lib/headplane/agent"',
})
.narrow((obj: Record<string, unknown>, ctx: any) => {
const hasVal = obj.pre_authkey != null && `${obj.pre_authkey}` !== '';
const hasPath = obj.pre_authkey_path != null && obj.pre_authkey_path !== '';
if (hasVal && hasPath)
return ctx.reject(
`Only one of "pre_authkey" or "pre_authkey_path" may be set.`,
);
if (!hasVal && !hasPath)
return ctx.reject(
`Either "pre_authkey" or "pre_authkey_path" must be provided.`,
);
return true;
})
.onDeepUndeclaredKey('reject');
const partialAgentConfig = type({
enabled: stringToBool.default(false),
host_name: 'string = "headplane-agent"',
pre_authkey: 'string?',
pre_authkey_path: 'string?',
cache_ttl: 'number.integer = 180000', cache_ttl: 'number.integer = 180000',
cache_path: 'string = "/var/lib/headplane/agent_cache.json"', cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
executable_path: 'string = "/usr/libexec/headplane/agent"', executable_path: 'string = "/usr/libexec/headplane/agent"',
work_dir: 'string = "/var/lib/headplane/agent"', work_dir: 'string = "/var/lib/headplane/agent"',
}); });
const partialAgentConfig = type({
enabled: stringToBool,
host_name: 'string | undefined',
pre_authkey: 'string | undefined',
cache_ttl: 'number.integer | undefined',
cache_path: 'string | undefined',
executable_path: 'string | undefined',
work_dir: 'string | undefined',
}).partial();
const dockerConfig = type({ const dockerConfig = type({
enabled: stringToBool, enabled: stringToBool,
container_name: 'string = ""', container_name: 'string = ""',
@@ -114,10 +213,10 @@ export const headplaneConfig = type({
export const partialHeadplaneConfig = type({ export const partialHeadplaneConfig = type({
debug: stringToBool, debug: stringToBool,
server: serverConfig.partial(), server: partialServerConfig,
'oidc?': oidcConfig.partial(), 'oidc?': partialOidcConfig,
'integration?': partialIntegrationConfig, 'integration?': partialIntegrationConfig,
headscale: headscaleConfig.partial(), headscale: partialHeadscaleConfig,
}).partial(); }).partial();
export type HeadplaneConfig = typeof headplaneConfig.infer; export type HeadplaneConfig = typeof headplaneConfig.infer;
+28
View File
@@ -0,0 +1,28 @@
import { mkdir } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { migrate } from 'drizzle-orm/libsql/migrator';
import { drizzle } from 'drizzle-orm/libsql/sqlite3';
import log from '~/utils/log';
export async function createDbClient(path: string) {
const realPath = resolve(path);
try {
await mkdir(dirname(realPath), { recursive: true });
} catch (error) {
log.error(
'server',
'Failed to create directory for database at %s: %s',
realPath,
error instanceof Error ? error.message : String(error),
);
throw new Error(`Could not create directory for database at ${realPath}`);
}
// Turn the path into a URL with the file protocol
const db = drizzle(`file://${realPath}`);
migrate(db, {
migrationsFolder: './drizzle',
});
return db;
}
+55
View File
@@ -0,0 +1,55 @@
import { eq, isNotNull } from 'drizzle-orm';
import { LoaderFunctionArgs } from 'react-router';
import { Machine } from '~/types';
import log from '~/utils/log';
import { LoadContext } from '..';
import { ephemeralNodes } from './schema';
export async function pruneEphemeralNodes({
context,
request,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request);
const ephemerals = await context.db
.select()
.from(ephemeralNodes)
.where(isNotNull(ephemeralNodes.node_key));
if (ephemerals.length === 0) {
log.debug('api', 'No ephemeral nodes to prune');
return;
}
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.api_key,
);
const toPrune = nodes.filter((node) => {
if (node.online) {
return false;
}
return ephemerals.some((ephemeral) => node.nodeKey === ephemeral.node_key);
});
if (toPrune.length === 0) {
log.debug('api', 'No SSH nodes to prune');
return;
}
// Delete from the Headscale nodes list and then from the database
const promises = toPrune.map((node) => {
return async () => {
log.debug('api', `Pruning node ${node.name}`);
await context.client.delete(`v1/node/${node.id}`, session.api_key);
await context.db
.delete(ephemeralNodes)
.where(eq(ephemeralNodes.node_key, node.nodeKey));
log.debug('api', `Node ${node.name} pruned successfully`);
};
});
await Promise.all(promises.map((p) => p()));
}
+31
View File
@@ -0,0 +1,31 @@
import { integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { HostInfo } from '~/types';
export const ephemeralNodes = sqliteTable('ephemeral_nodes', {
auth_key: text('auth_key').primaryKey(),
node_key: text('node_key'),
});
export type EphemeralNode = typeof ephemeralNodes.$inferSelect;
export type EphemeralNodeInsert = typeof ephemeralNodes.$inferInsert;
export const hostInfo = sqliteTable('host_info', {
host_id: text('host_id').primaryKey(),
payload: text('payload', { mode: 'json' }).$type<HostInfo>(),
updated_at: integer('updated_at', { mode: 'timestamp' }).$default(
() => new Date(),
),
});
export type HostInfoRecord = typeof hostInfo.$inferSelect;
export type HostInfoInsert = typeof hostInfo.$inferInsert;
export const users = sqliteTable('users', {
id: text('id').primaryKey(),
sub: text('sub').notNull().unique(),
caps: integer('caps').notNull().default(0),
onboarded: integer('onboarded', { mode: 'boolean' }).notNull().default(false),
});
export type User = typeof users.$inferSelect;
export type UserInsert = typeof users.$inferInsert;
+2 -1
View File
@@ -14,7 +14,7 @@ const databaseConfig = type({
type: '"sqlite" | "sqlite3"', type: '"sqlite" | "sqlite3"',
sqlite: { sqlite: {
path: 'string', path: 'string',
write_head_log: goBool.default(true), write_ahead_log: goBool.default(true),
wal_autocheckpoint: 'number = 1000', wal_autocheckpoint: 'number = 1000',
}, },
}) })
@@ -103,6 +103,7 @@ export const headscaleConfig = type({
dns: { dns: {
magic_dns: goBool.default(true), magic_dns: goBool.default(true),
base_domain: 'string = "headscale.net"', base_domain: 'string = "headscale.net"',
override_local_dns: goBool.default(false),
nameservers: type({ nameservers: type({
global: type('string[]').default(() => []), global: type('string[]').default(() => []),
split: type('Record<string, string[]>').default(() => ({})), split: type('Record<string, string[]>').default(() => ({})),
+420
View File
@@ -0,0 +1,420 @@
import { ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
import EventEmitter from 'node:events';
import { access, constants, mkdir, open } from 'node:fs/promises';
import { getegid, geteuid } from 'node:process';
import { createInterface, Interface } from 'node:readline';
import { inArray } from 'drizzle-orm';
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver-core';
import { HostInfo } from '~/types';
import log from '~/utils/log';
import { HeadplaneConfig } from './config/schema';
import { hostInfo } from './db/schema';
export async function createHeadplaneAgent(
config: NonNullable<HeadplaneConfig['integration']>['agent'] | undefined,
headscaleUrl: string,
db: LibSQLDatabase,
) {
if (!config?.enabled) {
return;
}
if (!config.pre_authkey) {
log.error('agent', 'Agent `pre_authkey` is not set');
log.warn('agent', 'The agent will not run until resolved');
return;
}
try {
await access(config.work_dir, constants.R_OK | constants.W_OK);
log.debug('config', 'Using agent work dir at %s', config.work_dir);
} catch (error) {
// Try to create the directory just in case
try {
await mkdir(config.work_dir, { recursive: true });
log.debug('config', 'Created agent work dir at %s', config.work_dir);
log.info(
'config',
'Created missing agent work dir at %s',
config.work_dir,
);
return;
} catch (innerError) {
log.error(
'config',
'Failed to create agent work dir at %s',
config.work_dir,
);
log.info(
'config',
'Agent work dir not accessible at %s',
config.work_dir,
);
log.debug('config', 'Error details: %s', error);
log.debug('config', 'Create error details: %s', innerError);
return;
}
}
try {
const handle = await open(config.cache_path, 'a+');
log.info('agent', 'Using agent cache file at %s', config.cache_path);
await handle.close();
} catch (error) {
log.info(
'agent',
'Agent cache file not accessible at %s',
config.cache_path,
);
log.debug('agent', 'Error details: %s', error);
return;
}
const agent = new HeadplaneAgent({
...config,
headscaleUrl,
});
agent.on('spawn', () => {
log.info('agent', 'Headplane agent started');
});
agent.on('ready', () => {
log.info('agent', 'Headplane agent is ready and serving queries');
});
agent.on('error', (err) => {
log.warn('agent', 'Headplane agent experienced an error: %s', err.message);
log.debug('agent', 'Error details: %o', err);
});
agent.on('exit', ({ code, signal }) => {
log.warn(
'agent',
'Headplane agent exited with code %s and signal %s',
code,
signal,
);
});
agent.on('restart', ({ delay, attempt }) => {
log.warn(
'agent',
'Headplane agent will restart in %f seconds (attempt %d)',
delay / 1000,
attempt,
);
});
agent.on('stderr', (data) => {
log.error('agent', 'Headplane agent stderr:', data);
});
agent.on('info', async ({ id, info }) => {
log.debug('agent', 'Received HostInfo for %s', id);
try {
const parsedInfo = JSON.parse(info) as HostInfo;
await db
.insert(hostInfo)
.values({
host_id: id,
payload: parsedInfo,
updated_at: new Date(),
})
.onConflictDoUpdate({
target: hostInfo.host_id,
set: {
payload: parsedInfo,
updated_at: new Date(),
},
});
} catch (error) {
log.error(
'agent',
'Failed to parse HostInfo for %s: %s',
id,
error instanceof Error ? error.message : String(error),
);
return;
}
});
agent.start();
process.on('SIGTERM', () => agent.shutdown());
process.on('SIGINT', () => agent.shutdown());
return {
agentID: () => agent.agentID(),
lookup: async (nodes: string[]) => {
const results = await db
.select()
.from(hostInfo)
.where(inArray(hostInfo.host_id, nodes));
return Object.fromEntries(
results.filter((r) => r.payload).map((r) => [r.host_id, r.payload]),
) as Record<string, HostInfo>;
},
};
}
type AgentOptions = NonNullable<
NonNullable<HeadplaneConfig['integration']>['agent']
> & {
headscaleUrl: string;
};
interface AgentEvents {
ready: [];
spawn: [];
error: [Error];
exit: [{ code?: number; signal?: NodeJS.Signals }];
restart: [{ delay: number; attempt: number }];
stderr: [string];
info: [{ id: string; info: string }];
}
/**
* A custom class that turns the lifecycle of the agent into an event emitter.
* It has many different responsibilities ensuring that:
* - The agent is spawned with the correct configuration
* - The agent is ready and still running (ping/heartbeat)
* - The agent is restarted on a backoff strategy
*/
class HeadplaneAgent extends EventEmitter<AgentEvents> {
private child?: ChildProcessWithoutNullStreams;
private readline?: Interface;
private options: AgentOptions;
private hbInterval?: NodeJS.Timeout;
private hbDeadline?: NodeJS.Timeout;
private restartTimer?: NodeJS.Timeout;
private isWaitingForAck = false;
private isShuttingDown = false;
private backoffAttempt = 0;
private agentId?: string;
private BASE_BACKOFF_MS = 1.5 * 1000; // 1.5 seconds
private MAX_BACKOFF_MS = 30 * 1000; // 30 seconds
private PROBE_COOLDOWN_MS = 5 * 60_000; // 5 minutes
private PROBE_ATTEMPT_INTERVAL = 10; // Every 10th attempt
private HEARTBEAT_INTERVAL_MS = 5 * 1000; // 5 seconds
private HEARTBEAT_TIMEOUT_MS = 3 * 1000; // 3 seconds
constructor(options: AgentOptions) {
super();
this.options = options;
}
agentID() {
return this.agentId;
}
start() {
this.isShuttingDown = false;
this.spawnInternalChild();
}
shutdown() {
this.isShuttingDown = true;
this.agentId = undefined;
clearTimeout(this.restartTimer);
clearInterval(this.hbInterval);
clearTimeout(this.hbDeadline);
this.isWaitingForAck = false;
this.send('SHUTDOWN');
this.child?.kill('SIGTERM');
this.readline?.close();
}
private spawnInternalChild() {
this.child = spawn(this.options.executable_path, {
stdio: ['pipe', 'pipe', 'pipe'],
uid: geteuid?.() ?? undefined,
gid: getegid?.() ?? undefined,
env: {
HOME: process.env.HOME,
HEADPLANE_AGENT_WORK_DIR: this.options.work_dir,
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? 'true' : 'false',
HEADPLANE_AGENT_HOSTNAME: this.options.host_name,
HEADPLANE_AGENT_TS_SERVER: this.options.headscaleUrl,
HEADPLANE_AGENT_TS_AUTHKEY: this.options.pre_authkey,
},
});
this.emit('spawn');
this.child.on('error', (err) => this.emit('error', err));
this.child.stderr.on('data', (data) =>
this.emit('stderr', data.toString()),
);
this.child.on('exit', (code, signal) => {
this.agentId = undefined;
this.emit('exit', {
code: code ?? undefined,
signal: signal ?? undefined,
});
this.readline?.close();
clearInterval(this.hbInterval);
clearTimeout(this.hbDeadline);
this.isWaitingForAck = false;
if (this.isShuttingDown) {
log.info('agent', 'Child process exited gracefully');
return;
}
this.backoffAttempt++;
const delay = this.calculateBackoff();
this.emit('restart', { delay, attempt: this.backoffAttempt });
this.restartTimer = setTimeout(() => this.spawnInternalChild(), delay);
});
this.readline = createInterface({ input: this.child.stdout });
this.readline.on('line', (line) => this.readlineHandler(line));
this.send('START');
// Start the heartbeat loop with our custom interval
this.hbInterval = setInterval(() => {
if (!this.child || this.child.killed) return;
// If we get here, we missed the last PONG response and can die
if (this.isWaitingForAck) {
this.agentId = undefined;
this.emit('error', new Error('Agent heartbeat missed'));
this.child.kill('SIGTERM');
return;
}
this.isWaitingForAck = true;
this.send('PING');
clearTimeout(this.hbDeadline);
this.hbDeadline = setTimeout(() => {
if (this.isWaitingForAck) {
this.agentId = undefined;
this.emit('error', new Error('Agent heartbeat timeout'));
this.child?.kill('SIGTERM');
}
}, this.HEARTBEAT_TIMEOUT_MS);
}, this.HEARTBEAT_INTERVAL_MS);
}
private send(s: string) {
if (!this.child || this.child.killed) return;
const ok = this.child.stdin.write(`${s}\n`);
if (!ok) this.child.stdin.once('drain', () => {});
}
/**
* Calculates a backoff time based on the current attempt.
* Supports a randomized jitter to avoid thundering herd problems.
*
* @param min The minimum backoff time in milliseconds.
* @param max The maximum backoff time in milliseconds.
* @returns The calculated backoff time in milliseconds.
*/
private calculateBackoff() {
const attempt = this.backoffAttempt;
if (attempt > 0 && attempt % this.PROBE_ATTEMPT_INTERVAL === 0) {
const jitter = Math.floor(Math.random() * (this.MAX_BACKOFF_MS + 1));
const sign = Math.random() < 0.5 ? -1 : 1;
return Math.max(0, this.PROBE_COOLDOWN_MS + jitter * sign);
}
const cap = Math.min(
this.MAX_BACKOFF_MS,
this.BASE_BACKOFF_MS * 2 ** attempt,
);
return Math.floor(Math.random() * (cap + 1));
}
/**
* Processes and dispatches the appropriate response based on the message.
* @param line The message to process (piped straight from readline)
*/
private readlineHandler(line: string) {
// When we are ready we force a refresh so that the UI has the most
// up-to-date information and will gracefully handle new info being sent
if (line.startsWith('READY')) {
this.backoffAttempt = 0;
this.send('REFRESH');
this.emit('ready');
const agentId = line.slice(5).trim();
if (this.agentId && this.agentId !== agentId) {
log.warn(
'agent',
'Agent ID changed from %s to %s',
this.agentId,
agentId,
);
}
this.agentId = agentId;
return;
}
if (line.startsWith('PONG')) {
this.isWaitingForAck = false;
clearTimeout(this.hbDeadline);
const agentId = line.slice(5).trim();
if (this.agentId && this.agentId !== agentId) {
log.warn(
'agent',
'Agent ID changed from %s to %s',
this.agentId,
agentId,
);
}
this.agentId = agentId;
return;
}
if (line.startsWith('HOSTINFO')) {
const data = line.slice(9).trim();
const [id, ...infoParts] = data.split(' ');
const info = infoParts.join(' ');
this.emit('info', { id, info });
return;
}
if (line.startsWith('ERROR')) {
const error = line.slice(6).trim();
this.emit('error', new Error(error));
return;
}
if (line.startsWith('LOG')) {
const logSnippet = line.slice(4).trim();
const [level, ...messageParts] = logSnippet.split(' ');
const message = messageParts.join(' ');
switch (level) {
case 'INFO':
log.info('agent', message);
break;
case 'WARN':
log.warn('agent', message);
break;
case 'ERROR':
log.error('agent', message);
break;
default:
log.debug('agent', message);
}
return;
}
}
}
+45 -15
View File
@@ -1,14 +1,15 @@
import { join } from 'node:path';
import { env, versions } from 'node:process'; import { env, versions } from 'node:process';
import { createHonoServer } from 'react-router-hono-server/node'; import { createHonoServer } from 'react-router-hono-server/node';
import log from '~/utils/log'; import log from '~/utils/log';
import { configureConfig, configureLogger, envVariables } from './config/env'; import { configureConfig, configureLogger, envVariables } from './config/env';
import { loadIntegration } from './config/integration'; import { loadIntegration } from './config/integration';
import { loadConfig } from './config/loader'; import { loadConfig } from './config/loader';
import { createDbClient } from './db/client.server';
import { createApiClient } from './headscale/api-client'; import { createApiClient } from './headscale/api-client';
import { loadHeadscaleConfig } from './headscale/config-loader'; import { loadHeadscaleConfig } from './headscale/config-loader';
import { loadAgentSocket } from './web/agent'; import { createHeadplaneAgent } from './hp-agent';
import { createOidcClient } from './web/oidc'; import { configureOidcAuth } from './web/oidc';
import { createSessionStorage } from './web/sessions'; import { createSessionStorage } from './web/sessions';
declare global { declare global {
@@ -28,6 +29,13 @@ const config = await loadConfig(
}), }),
); );
const db = await createDbClient(join(config.server.data_path, 'hp_persist.db'));
const agents = await createHeadplaneAgent(
config.integration?.agent,
config.headscale.url,
db,
);
// We also use this file to load anything needed by the react router code. // We also use this file to load anything needed by the react router code.
// These are usually per-request things that we need access to, like the // These are usually per-request things that we need access to, like the
// helper that can issue and revoke cookies. // helper that can issue and revoke cookies.
@@ -41,27 +49,27 @@ const appLoadContext = {
), ),
// TODO: Better cookie options in config // TODO: Better cookie options in config
sessions: await createSessionStorage( sessions: await createSessionStorage({
{ secret: config.server.cookie_secret,
name: '_hp_session', db,
maxAge: 60 * 60 * 24, // 24 hours oidcUsersFile: config.oidc?.user_storage_file,
cookie: {
name: '_hp_auth',
secure: config.server.cookie_secure, secure: config.server.cookie_secure,
secrets: [config.server.cookie_secret], maxAge: 60 * 60 * 24, // 24 hours
// domain: config.server.cookie_domain,
}, },
config.oidc?.user_storage_file, }),
),
client: await createApiClient( client: await createApiClient(
config.headscale.url, config.headscale.url,
config.headscale.tls_cert_path, config.headscale.tls_cert_path,
), ),
agents: await loadAgentSocket( agents,
config.integration?.agent,
config.headscale.url,
),
integration: await loadIntegration(config.integration), integration: await loadIntegration(config.integration),
oidc: config.oidc ? await createOidcClient(config.oidc) : undefined, oidc: config.oidc ? await configureOidcAuth(config.oidc) : undefined,
db,
}; };
declare module 'react-router' { declare module 'react-router' {
@@ -72,6 +80,18 @@ export default createHonoServer({
overrideGlobalObjects: true, overrideGlobalObjects: true,
port: config.server.port, port: config.server.port,
hostname: config.server.host, hostname: config.server.host,
beforeAll: async (app) => {
app.use(__PREFIX__, async (c) => {
return c.redirect(`${__PREFIX__}/`);
});
},
serveStaticOptions: {
clientAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ''),
},
},
// Only log in development mode // Only log in development mode
defaultLogger: import.meta.env.DEV, defaultLogger: import.meta.env.DEV,
@@ -86,3 +106,13 @@ export default createHonoServer({
log.info('server', 'Running on %s:%s', info.address, info.port); log.info('server', 'Running on %s:%s', info.address, info.port);
}, },
}); });
process.on('SIGINT', () => {
log.info('server', 'Received SIGINT, shutting down...');
process.exit(0);
});
process.on('SIGTERM', () => {
log.info('server', 'Received SIGTERM, shutting down...');
process.exit(0);
});
-441
View File
@@ -1,441 +0,0 @@
import { ChildProcess, spawn } from 'node:child_process';
import { createHash } from 'node:crypto';
import {
constants,
access,
mkdir,
open,
readFile,
writeFile,
} from 'node:fs/promises';
import { exit } from 'node:process';
import { createInterface } from 'node:readline';
import { setTimeout } from 'node:timers/promises';
import { type } from 'arktype';
import { HostInfo } from '~/types';
import log from '~/utils/log';
import type { HeadplaneConfig } from '../config/schema';
interface LogResponse {
Level: 'info' | 'debug' | 'error' | 'fatal';
Message: string;
}
interface RegisterMessage {
Type: 'register';
ID: string;
}
interface StatusMessage {
Type: 'status';
Data: Record<string, HostInfo>;
}
interface MessageResponse {
Level: 'msg';
Message: RegisterMessage | StatusMessage;
}
type AgentResponse = LogResponse | MessageResponse;
export async function loadAgentSocket(
config: NonNullable<HeadplaneConfig['integration']>['agent'] | undefined,
headscaleUrl: string,
) {
if (!config?.enabled) {
return;
}
if (config.pre_authkey.trim().length === 0) {
log.error('agent', 'Agent `pre_authkey` is not set');
log.warn('agent', 'The agent will not run until resolved');
return;
}
try {
await access(config.work_dir, constants.R_OK | constants.W_OK);
log.debug('config', 'Using agent work dir at %s', config.work_dir);
} catch (error) {
// Try to create the directory just in case
try {
await mkdir(config.work_dir, { recursive: true });
log.debug('config', 'Created agent work dir at %s', config.work_dir);
log.info(
'config',
'Created missing agent work dir at %s',
config.work_dir,
);
return;
} catch (innerError) {
log.error(
'config',
'Failed to create agent work dir at %s',
config.work_dir,
);
log.info(
'config',
'Agent work dir not accessible at %s',
config.work_dir,
);
log.debug('config', 'Error details: %s', error);
log.debug('config', 'Create error details: %s', innerError);
return;
}
}
try {
const handle = await open(config.cache_path, 'a+');
log.info('agent', 'Using agent cache file at %s', config.cache_path);
await handle.close();
} catch (error) {
log.info(
'agent',
'Agent cache file not accessible at %s',
config.cache_path,
);
log.debug('agent', 'Error details: %s', error);
return;
}
const cache = new TimedCache<HostInfo>(config.cache_ttl, config.cache_path);
return new AgentManager(cache, config, headscaleUrl);
}
class AgentManager {
private static readonly MAX_RESTARTS = 5;
private restartCounter = 0;
private cache: TimedCache<HostInfo>;
private headscaleUrl: string;
private config: NonNullable<
NonNullable<HeadplaneConfig['integration']>['agent']
>;
private spawnProcess: ChildProcess | null;
private agentId: string | null;
constructor(
cache: TimedCache<HostInfo>,
config: NonNullable<NonNullable<HeadplaneConfig['integration']>['agent']>,
headscaleUrl: string,
) {
this.cache = cache;
this.config = config;
this.headscaleUrl = headscaleUrl;
this.spawnProcess = null;
this.agentId = null;
this.startAgent();
process.on('SIGINT', () => {
this.spawnProcess?.kill();
exit(0);
});
process.on('SIGTERM', () => {
this.spawnProcess?.kill();
exit(0);
});
}
/**
* Used by the UI to indicate why the agent is not running.
* Exhaustion requires a manual restart of the agent.
* (Which can be invoked via the UI)
* @returns true if the agent is exhausted
*/
exhausted() {
return this.restartCounter >= AgentManager.MAX_RESTARTS;
}
/*
* Called by the UI to manually force a restart of the agent.
*/
deExhaust() {
this.restartCounter = 0;
this.startAgent();
}
/*
* Stored agent ID for the current process. This is caught by the agent
* when parsing the stdout on agent startup.
*/
agentID() {
return this.agentId;
}
private startAgent() {
if (this.spawnProcess) {
log.debug('agent', 'Agent already running');
return;
}
if (this.exhausted()) {
log.error('agent', 'Agent is exhausted, cannot start');
return;
}
// Cannot be detached since we want to follow our process lifecycle
// We also need to be able to send data to the process by using stdin
log.info(
'agent',
'Starting agent process (attempt %d)',
this.restartCounter,
);
this.spawnProcess = spawn(this.config.executable_path, [], {
detached: false,
stdio: ['pipe', 'pipe', 'pipe'],
env: {
HOME: process.env.HOME,
HEADPLANE_EMBEDDED: 'true',
HEADPLANE_AGENT_WORK_DIR: this.config.work_dir,
HEADPLANE_AGENT_DEBUG: log.debugEnabled ? 'true' : 'false',
HEADPLANE_AGENT_HOSTNAME: this.config.host_name,
HEADPLANE_AGENT_TS_SERVER: this.headscaleUrl,
HEADPLANE_AGENT_TS_AUTHKEY: this.config.pre_authkey,
},
});
if (!this.spawnProcess?.pid) {
log.error('agent', 'Failed to start agent process');
this.restartCounter++;
global.setTimeout(() => this.startAgent(), 1000);
return;
}
if (this.spawnProcess.stdin === null || this.spawnProcess.stdout === null) {
log.error('agent', 'Failed to connect to agent process');
this.restartCounter++;
global.setTimeout(() => this.startAgent(), 1000);
return;
}
const rlStdout = createInterface({
input: this.spawnProcess.stdout,
crlfDelay: Number.POSITIVE_INFINITY,
});
rlStdout.on('line', (line) => {
try {
const parsed = JSON.parse(line) as AgentResponse;
if (parsed.Level === 'msg') {
switch (parsed.Message.Type) {
case 'register':
this.agentId = parsed.Message.ID;
break;
case 'status':
for (const [key, value] of Object.entries(parsed.Message.Data)) {
// Mark the agent as the one that is running
// We store it in the cache so that it shows
// itself later
if (key === this.agentId) {
value.HeadplaneAgent = true;
}
this.cache.set(key, value);
}
break;
}
return;
}
switch (parsed.Level) {
case 'info':
case 'debug':
case 'error':
log[parsed.Level]('agent', parsed.Message);
break;
case 'fatal':
log.error('agent', parsed.Message);
break;
default:
log.debug('agent', 'Unknown agent response: %s', line);
break;
}
} catch (error) {
log.debug('agent', 'Failed to parse agent response: %s', error);
log.debug('agent', 'Raw data: %s', line);
}
});
this.spawnProcess.on('error', (error) => {
log.error('agent', 'Failed to start agent process: %s', error);
this.restartCounter++;
this.spawnProcess = null;
global.setTimeout(() => this.startAgent(), 1000);
});
this.spawnProcess.on('exit', (code) => {
log.error('agent', 'Agent process exited with code %d', code ?? -1);
this.restartCounter++;
this.spawnProcess = null;
global.setTimeout(() => this.startAgent(), 1000);
});
}
async lookup(nodeIds: string[]) {
const entries = this.cache.toJSON();
const missing = nodeIds.filter((nodeId) => !entries[nodeId]);
if (missing.length > 0) {
await this.requestData(missing);
}
return Object.entries(entries).reduce<Record<string, HostInfo>>(
(acc, [key, value]) => {
if (nodeIds.includes(key)) {
acc[key] = value;
}
return acc;
},
{},
);
}
// Request data from the internal agent by sending a message to the process
// via stdin. This is a blocking call, so it will wait for the agent to
// respond before returning.
private async requestData(nodeList: string[]) {
if (this.exhausted()) {
return;
}
// Wait for the process to be spawned, busy waiting is gross
while (this.spawnProcess === null) {
await setTimeout(100);
}
// Send the request to the agent, without waiting for a response.
// The live data invalidator will re-request the data if it is not
// available in the cache anyways.
const data = JSON.stringify({ NodeIDs: nodeList });
this.spawnProcess.stdin?.write(`${data}\n`);
}
}
const diskSchema = type({
key: 'string',
value: 'unknown',
expires: 'number?',
}).array();
// A persistent HashMap with a TTL for each key
class TimedCache<V> {
private _cache = new Map<string, V>();
private _timings = new Map<string, number>();
// Default TTL is 1 minute
private defaultTTL: number;
private filePath: string;
private writeLock = false;
// Last flush ID is essentially a hash of the flush contents
// Prevents unnecessary flushing if nothing has changed
private lastFlushId = '';
constructor(defaultTTL: number, filePath: string) {
this.defaultTTL = defaultTTL;
this.filePath = filePath;
// Load the cache from disk and then queue flushes every 10 seconds
this.load().then(() => {
setInterval(() => this.flush(), 10000);
});
}
set(key: string, value: V, ttl: number = this.defaultTTL) {
this._cache.set(key, value);
this._timings.set(key, Date.now() + ttl);
}
get(key: string) {
const value = this._cache.get(key);
if (!value) {
return;
}
const expires = this._timings.get(key);
if (!expires || expires < Date.now()) {
this._cache.delete(key);
this._timings.delete(key);
return;
}
return value;
}
// Map into a Record without any TTLs
toJSON() {
const result: Record<string, V> = {};
for (const [key, value] of this._cache.entries()) {
result[key] = value;
}
return result;
}
// WARNING: This function expects that this.filePath is NOT ENOENT
private async load() {
const data = await readFile(this.filePath, 'utf-8');
const cache = () => {
try {
return JSON.parse(data);
} catch (e) {
return undefined;
}
};
const diskData = cache();
if (diskData === undefined) {
log.error('agent', 'Failed to load cache at %s', this.filePath);
return;
}
const cacheData = diskSchema(diskData);
if (cacheData instanceof type.errors) {
log.debug('agent', 'Failed to load cache at %s', this.filePath);
log.debug('agent', 'Error details: %s', cacheData.toString());
// Skip loading the cache (it should be overwritten soon)
return;
}
for (const { key, value, expires } of diskData) {
this._cache.set(key, value);
this._timings.set(key, expires);
}
log.info('agent', 'Loaded cache from %s', this.filePath);
}
private async flush() {
const data = Array.from(this._cache.entries()).map(([key, value]) => {
return { key, value, expires: this._timings.get(key) };
});
if (data.length === 0) {
return;
}
// Calculate the hash of the data
const dumpData = JSON.stringify(data);
const sha = createHash('sha256').update(dumpData).digest('hex');
if (sha === this.lastFlushId) {
return;
}
// We need to lock the writeLock so that we don't try to write
// to the file while we're already writing to it
while (this.writeLock) {
await setTimeout(100);
}
this.writeLock = true;
await writeFile(this.filePath, dumpData, 'utf-8');
log.debug('agent', 'Flushed cache to %s', this.filePath);
this.lastFlushId = sha;
this.writeLock = false;
}
}
+105 -110
View File
@@ -1,90 +1,37 @@
import { readFile } from 'node:fs/promises'; import * as oidc from 'openid-client';
import * as client from 'openid-client';
import log from '~/utils/log'; import log from '~/utils/log';
import type { HeadplaneConfig } from '../config/schema'; import { HeadplaneConfig } from '../config/schema';
async function loadClientSecret(path: string) { export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
// We need to interpolate environment variables into the path
// Path formatting can be like ${ENV_NAME}/path/to/secret
const matches = path.match(/\${(.*?)}/g);
let resolvedPath = path;
if (matches) { export async function configureOidcAuth(config: OidcConfig) {
for (const match of matches) { log.debug('config', 'Running OIDC discovery for %s', config.issuer);
const env = match.slice(2, -1); let clientAuthMethod: oidc.ClientAuth;
const value = process.env[env]; switch (config.token_endpoint_auth_method) {
if (!value) {
log.error('config', 'Environment variable %s is not set', env);
return;
}
log.debug('config', 'Interpolating %s with %s', match, value);
resolvedPath = resolvedPath.replace(match, value);
}
}
try {
log.debug('config', 'Reading client secret from %s', resolvedPath);
const secret = await readFile(resolvedPath, 'utf-8');
if (secret.trim().length === 0) {
log.error('config', 'Empty OIDC client secret');
return;
}
return secret.trim();
} catch (error) {
log.error('config', 'Failed to read client secret from %s', path);
log.error('config', 'Error: %s', error);
log.debug('config', 'Error details: %o', error);
}
}
function clientAuthMethod(
method: string,
): (secret: string) => client.ClientAuth {
switch (method) {
case 'client_secret_post':
return client.ClientSecretPost;
case 'client_secret_basic': case 'client_secret_basic':
return client.ClientSecretBasic; clientAuthMethod = oidc.ClientSecretBasic(config.client_secret!);
break;
case 'client_secret_post':
clientAuthMethod = oidc.ClientSecretPost(config.client_secret!);
break;
case 'client_secret_jwt': case 'client_secret_jwt':
return client.ClientSecretJwt; clientAuthMethod = oidc.ClientSecretJwt(config.client_secret!);
break;
default: default:
throw new Error('Invalid client authentication method'); throw new Error('Invalid client authentication method');
} }
}
// Loads and configures an OIDC client to support OIDC authentication. let oidcClient: oidc.Configuration;
// This runs under the assumption the OIDC configuration exists and is valid.
// If it is invalid, Headplane automatically disables it.
//
// TODO: Support custom endpoints instead of relying on OIDC discovery.
// This will enable us to support servers like GitHub that do not support
// nor advertise a .well-known endpoint.
export async function createOidcClient(
config: NonNullable<HeadplaneConfig['oidc']>,
) {
// const secret = await loadClientSecret(oidc);
const secret = config.client_secret_path
? await loadClientSecret(config.client_secret_path)
: config.client_secret;
if (!secret) {
log.error('config', 'Missing an OIDC client secret');
return;
}
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
try { try {
const oidc = await client.discovery( const discovery = await oidc.discovery(
new URL(config.issuer), new URL(config.issuer),
config.client_id, config.client_id,
secret, config.client_secret!, // TODO: Fix this config schema
clientAuthMethod(config.token_endpoint_auth_method)(secret), clientAuthMethod,
); );
const metadata = oidc.serverMetadata(); const meta = discovery.serverMetadata();
if (!metadata.authorization_endpoint) { if (!meta.authorization_endpoint) {
log.error( log.error(
'config', 'config',
'Issuer discovery did not return `authorization_endpoint`', 'Issuer discovery did not return `authorization_endpoint`',
@@ -93,70 +40,118 @@ export async function createOidcClient(
'config', 'config',
'OIDC server does not support authorization code flow', 'OIDC server does not support authorization code flow',
); );
log.error('config', 'You may need to set this manually in the config');
return; return;
} }
if (!metadata.token_endpoint) { if (!meta.token_endpoint) {
log.error('config', 'Issuer discovery did not return `token_endpoint`'); log.error('config', 'Issuer discovery did not return `token_endpoint`');
log.error('config', 'OIDC server does not support token exchange'); log.error(
'config',
'OIDC server does not support authorization code flow',
);
log.error('config', 'You may need to set this manually in the config');
return; return;
} }
// If this field is missing, assume the server supports all response types if (!meta.userinfo_endpoint) {
// and that we can continue safely. log.error(
if (metadata.response_types_supported) { 'config',
if (!metadata.response_types_supported.includes('code')) { 'Issuer discovery did not return `userinfo_endpoint`',
log.error( );
'config', log.error('config', 'OIDC server does not support user info endpoint');
'Issuer discovery `response_types_supported` does not include `code`', log.error('config', 'You may need to set this manually in the config');
); return;
log.error('config', 'OIDC server does not support code flow');
return;
}
} }
if (metadata.token_endpoint_auth_methods_supported) { if (meta.token_endpoint_auth_methods_supported) {
if ( if (
!metadata.token_endpoint_auth_methods_supported.includes( !meta.token_endpoint_auth_methods_supported.includes(
config.token_endpoint_auth_method, config.token_endpoint_auth_method,
) )
) { ) {
log.error( log.error(
'config', 'config',
'Issuer discovery `token_endpoint_auth_methods_supported` does not include `%s`', 'OIDC server does not support client authentication method %s',
config.token_endpoint_auth_method, config.token_endpoint_auth_method,
); );
log.error( log.error(
'config', 'config',
'OIDC server does not support %s', 'Supported methods: %s',
config.token_endpoint_auth_method, meta.token_endpoint_auth_methods_supported.join(', '),
); );
return; return;
} }
} }
if (!metadata.userinfo_endpoint) { log.debug('config', 'OIDC discovery successful');
log.error(
'config',
'Issuer discovery did not return `userinfo_endpoint`',
);
log.error('config', 'OIDC server does not support userinfo endpoint');
return;
}
log.debug('config', 'OIDC client created successfully');
log.info('config', 'Using %s as the OIDC issuer', config.issuer);
log.debug( log.debug(
'config', 'config',
'Authorization endpoint: %s', 'Authorization endpoint: %s',
metadata.authorization_endpoint, meta.authorization_endpoint,
); );
log.debug('config', 'Token endpoint: %s', metadata.token_endpoint); log.debug('config', 'Token endpoint: %s', meta.token_endpoint);
log.debug('config', 'Userinfo endpoint: %s', metadata.userinfo_endpoint); log.debug('config', 'Userinfo endpoint: %s', meta.userinfo_endpoint);
return oidc;
} catch (error) { // Manually construct the endpoints to coalesce with config if needed
log.error('config', 'Failed to discover OIDC issuer'); oidcClient = new oidc.Configuration(
log.error('config', 'Error: %s', error); {
log.debug('config', 'Error details: %o', error); issuer: config.issuer,
authorization_endpoint:
config.authorization_endpoint || meta.authorization_endpoint,
token_endpoint: config.token_endpoint || meta.token_endpoint,
userinfo_endpoint: config.userinfo_endpoint || meta.userinfo_endpoint,
},
config.client_id,
config.client_secret!,
clientAuthMethod,
);
} catch (err) {
log.error('config', 'OIDC discovery failed: %s', err);
log.debug('config', 'Error details: %o', err);
log.error(
'config',
'This may be an error, or the server may not support discovery',
);
if (
!config.authorization_endpoint ||
!config.token_endpoint ||
!config.userinfo_endpoint
) {
log.error(
'config',
'Endpoints are not fully configured, cannot continue',
);
log.error(
'config',
'You must set authorization_endpoint, token_endpoint and userinfo_endpoint manually in the config or fix the discovery issue',
);
return;
}
oidcClient = new oidc.Configuration(
{
issuer: config.issuer,
authorization_endpoint: config.authorization_endpoint,
token_endpoint: config.token_endpoint,
userinfo_endpoint: config.userinfo_endpoint,
},
config.client_id,
config.client_secret!,
clientAuthMethod,
);
log.debug('config', 'Using manually configured endpoints');
log.debug(
'config',
'Authorization endpoint: %s',
config.authorization_endpoint,
);
log.debug('config', 'Token endpoint: %s', config.token_endpoint);
log.debug('config', 'Userinfo endpoint: %s', config.userinfo_endpoint);
} }
log.info('config', 'Successfully configured OIDC authentication');
return oidcClient;
} }
+226 -206
View File
@@ -1,13 +1,13 @@
import { open, readFile } from 'node:fs/promises'; import { createHash } from 'node:crypto';
import { open, readFile, rm } from 'node:fs/promises';
import { resolve } from 'node:path'; import { resolve } from 'node:path';
import { exit } from 'node:process'; import { eq } from 'drizzle-orm';
import { import { LibSQLDatabase } from 'drizzle-orm/libsql/driver';
CookieSerializeOptions, import { EncryptJWT, jwtDecrypt } from 'jose';
Session, import { createCookie } from 'react-router';
SessionStorage, import { ulid } from 'ulidx';
createCookieSessionStorage,
} from 'react-router';
import log from '~/utils/log'; import log from '~/utils/log';
import { users } from '../db/schema';
import { Capabilities, Roles } from './roles'; import { Capabilities, Roles } from './roles';
export interface AuthSession { export interface AuthSession {
@@ -22,6 +22,17 @@ export interface AuthSession {
}; };
} }
interface JWTSession {
api_key: string;
user: {
subject: string;
name: string;
email?: string;
username?: string;
picture?: string;
};
}
export interface OidcFlowSession { export interface OidcFlowSession {
state: 'flow'; state: 'flow';
oidc: { oidc: {
@@ -32,264 +43,243 @@ export interface OidcFlowSession {
}; };
} }
type JoinedSession = AuthSession | OidcFlowSession; interface AuthSessionOptions {
interface Error { secret: string;
error: string; db: LibSQLDatabase;
} oidcUsersFile?: string;
cookie: {
interface CookieOptions { name: string;
name: string; secure: boolean;
secure: boolean; maxAge: number;
maxAge: number; domain?: string;
secrets: string[]; };
domain?: string;
} }
class Sessionizer { class Sessionizer {
private storage: SessionStorage<JoinedSession, Error>; private options: AuthSessionOptions;
private caps: Record<string, { c: Capabilities; oo?: boolean }>;
private capsPath?: string;
constructor( constructor(options: AuthSessionOptions) {
options: CookieOptions, this.options = options;
caps: Record<string, { c: Capabilities; oo?: boolean }>,
capsPath?: string,
) {
this.caps = caps;
this.capsPath = capsPath;
this.storage = createCookieSessionStorage({
cookie: {
...options,
httpOnly: true,
path: __PREFIX__, // Only match on the prefix
sameSite: 'lax', // TODO: Strictify with Domain,
},
});
} }
// This throws on the assumption that auth is already checked correctly // This throws on the assumption that auth is already checked correctly
// on something that wraps the route calling auth. The top-level routes // on something that wraps the route calling auth. The top-level routes
// that call this are wrapped with try/catch to handle the error. // that call this are wrapped with try/catch to handle the error.
async auth(request: Request) { async auth(request: Request) {
const cookie = request.headers.get('cookie'); return decodeSession(request, this.options);
const session = await this.storage.getSession(cookie);
const type = session.get('state');
if (!type) {
throw new Error('Session state not found');
}
if (type !== 'auth') {
throw new Error('Session is not authenticated');
}
return session as Session<AuthSession, Error>;
} }
roleForSubject(subject: string): keyof typeof Roles | undefined { async createSession(
const role = this.caps[subject]?.c; payload: JWTSession,
if (!role) { maxAge = this.options.cookie.maxAge,
) {
// TODO: What the hell is this garbage
return createSession(payload, {
...this.options,
cookie: {
...this.options.cookie,
maxAge,
},
});
}
async destroySession() {
return destroySession(this.options);
}
async roleForSubject(
subject: string,
): Promise<keyof typeof Roles | undefined> {
const [user] = await this.options.db
.select()
.from(users)
.where(eq(users.sub, subject))
.limit(1);
if (!user) {
return; return;
} }
// We need this in string form based on Object.keys of the roles // We need this in string form based on Object.keys of the roles
for (const [key, value] of Object.entries(Roles)) { for (const [key, value] of Object.entries(Roles)) {
if (value === role) { if (value === user.caps) {
return key as keyof typeof Roles; return key as keyof typeof Roles;
} }
} }
} }
onboardForSubject(subject: string) {
return this.caps[subject]?.oo ?? false;
}
// Given an OR of capabilities, check if the session has the required // Given an OR of capabilities, check if the session has the required
// capabilities. If not, return false. Can throw since it calls auth() // capabilities. If not, return false. Can throw since it calls auth()
async check(request: Request, capabilities: Capabilities) { async check(request: Request, capabilities: Capabilities) {
const session = await this.auth(request); const session = await this.auth(request);
const { subject } = session.get('user') ?? {};
if (!subject) { // This is the subject we set on API key based sessions. API keys
// inherently imply admin access so we return true for all checks.
if (session.user.subject === 'unknown-non-oauth') {
return true;
}
const [user] = await this.options.db
.select()
.from(users)
.where(eq(users.sub, session.user.subject))
.limit(1);
if (!user) {
return false; return false;
} }
// This is the subject we set on API key based sessions. API keys return (capabilities & user.caps) === capabilities;
// inherently imply admin access so we return true for all checks.
if (subject === 'unknown-non-oauth') {
return true;
}
// If the role does not exist, then this is a new subject that we have
// not seen before. Since this is new, we set access to the lowest
// level by default which is the member role.
//
// This also allows us to avoid configuring preventing sign ups with
// OIDC, since the default sign up logic gives member which does not
// have access to the UI whatsoever.
const role = this.caps[subject];
if (!role) {
const memberRole = await this.registerSubject(subject);
return (capabilities & memberRole.c) === capabilities;
}
return (capabilities & role.c) === capabilities;
}
async checkSubject(subject: string, capabilities: Capabilities) {
// This is the subject we set on API key based sessions. API keys
// inherently imply admin access so we return true for all checks.
if (subject === 'unknown-non-oauth') {
return true;
}
// If the role does not exist, then this is a new subject that we have
// not seen before. Since this is new, we set access to the lowest
// level by default which is the member role.
//
// This also allows us to avoid configuring preventing sign ups with
// OIDC, since the default sign up logic gives member which does not
// have access to the UI whatsoever.
const role = this.caps[subject];
if (!role) {
const memberRole = await this.registerSubject(subject);
return (capabilities & memberRole.c) === capabilities;
}
return (capabilities & role.c) === capabilities;
}
// This code is very simple, if the user does not exist in the database
// file then we register it with the lowest level of access. If the user
// database is empty, the first user to sign in will be given the owner
// role.
private async registerSubject(subject: string) {
if (this.caps[subject]) {
return this.caps[subject];
}
if (Object.keys(this.caps).length === 0) {
log.debug('auth', 'First user registered as owner: %s', subject);
this.caps[subject] = { c: Roles.owner };
await this.flushUserDatabase();
return this.caps[subject];
}
log.debug('auth', 'New user registered as member: %s', subject);
this.caps[subject] = { c: Roles.member };
await this.flushUserDatabase();
return this.caps[subject];
}
private async flushUserDatabase() {
if (!this.capsPath) {
return;
}
const data = Object.entries(this.caps).map(([u, { c, oo }]) => ({
u,
c,
oo,
}));
try {
const handle = await open(this.capsPath, 'w');
await handle.write(JSON.stringify(data));
await handle.close();
} catch (error) {
log.error('config', 'Error writing user database file: %s', error);
}
} }
// Updates the capabilities and roles of a subject // Updates the capabilities and roles of a subject
async reassignSubject(subject: string, role: keyof typeof Roles) { async reassignSubject(subject: string, role: keyof typeof Roles) {
// Check if we are owner // Check if we are owner
if (this.roleForSubject(subject) === 'owner') { const subjectRole = await this.roleForSubject(subject);
if (subjectRole === 'owner') {
return false; return false;
} }
this.caps[subject] = { await this.options.db
...this.caps[subject], // Preserve the existing capabilities if any .update(users)
c: Roles[role], .set({
}; caps: Roles[role],
})
.where(eq(users.sub, subject));
await this.flushUserDatabase();
return true; return true;
} }
// Overrides the onboarding status for a subject
async overrideOnboarding(subject: string, onboarding: boolean) {
this.caps[subject] = {
...this.caps[subject], // Preserve the existing capabilities if any
oo: onboarding,
};
await this.flushUserDatabase();
}
getOrCreate<T extends JoinedSession = AuthSession>(request: Request) {
return this.storage.getSession(request.headers.get('cookie')) as Promise<
Session<T, Error>
>;
}
destroy(session: Session) {
return this.storage.destroySession(session);
}
commit(session: Session, options?: CookieSerializeOptions) {
return this.storage.commitSession(session, options);
}
} }
export async function createSessionStorage( async function createSession(payload: JWTSession, options: AuthSessionOptions) {
options: CookieOptions, const secret = createHash('sha256').update(options.secret, 'utf8').digest();
usersPath?: string, const jwt = await new EncryptJWT({
) { ...payload,
const map: Record< })
string, .setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' })
{ .setIssuedAt()
c: number; .setExpirationTime('1d')
oo?: boolean; .setIssuer('urn:tale:headplane')
} .setAudience('urn:tale:headplane')
> = {}; .setJti(ulid())
if (usersPath) { .encrypt(secret);
// We need to load our users from the file (default to empty map)
// We then translate each user into a capability object using the helper
// method defined in the roles.ts file
const data = await loadUserFile(usersPath);
log.debug('config', 'Loaded %d users from database', data.length);
for (const user of data) { const cookie = createCookie(options.cookie.name, {
map[user.u] = { ...options.cookie,
c: user.c, path: __PREFIX__,
oo: user.oo, });
};
}
}
return new Sessionizer(options, map, usersPath); return cookie.serialize(jwt);
} }
async function loadUserFile(path: string) { async function decodeSession(request: Request, options: AuthSessionOptions) {
const cookieHeader = request.headers.get('cookie');
if (cookieHeader === null) {
throw new Error('No session cookie found');
}
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
const cookieValue = (await cookie.parse(cookieHeader)) as string | null;
if (cookieValue === null) {
throw new Error('Session cookie is empty');
}
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
const { payload } = await jwtDecrypt(cookieValue, secret, {
issuer: 'urn:tale:headplane',
audience: 'urn:tale:headplane',
});
// Safe since we encode the session directly into the JWT
return payload as unknown as JWTSession;
}
async function destroySession(options: AuthSessionOptions) {
const cookie = createCookie(options.cookie.name, {
...options.cookie,
path: __PREFIX__,
});
return cookie.serialize('', {
expires: new Date(0),
});
}
export async function createSessionStorage(options: AuthSessionOptions) {
if (options.oidcUsersFile) {
await migrateUserDatabase(options.oidcUsersFile, options.db);
}
return new Sessionizer(options);
}
async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
log.info('config', 'Migrating old user database from %s', path);
const realPath = resolve(path); const realPath = resolve(path);
log.warn(
'config',
'oidc.user_storage_file is deprecated and will be removed in Headplane 0.7.0',
);
log.warn(
'config',
'You can ignore this warning if you do not use OIDC authentication.',
);
log.warn(
'config',
'Data will be automatically migrated to the new SQL database.',
);
log.warn(
'config',
'Refer to server.data_path to ensure this path is mounted correctly is using Docker.',
);
try { try {
const handle = await open(realPath, 'a+'); const handle = await open(realPath, 'a+');
log.info('config', 'Using user database file at %s', realPath);
await handle.close(); await handle.close();
} catch (error) { } catch (error) {
log.info('config', 'User database file not accessible at %s', realPath); log.warn('config', 'Failed to migrate old user database at %s', realPath);
log.warn(
'config',
'This is not an error, but existing users will not be migrated',
);
log.warn('config', 'Unable to open user database file: %s', error);
log.debug('config', 'Error details: %s', error); log.debug('config', 'Error details: %s', error);
exit(1); return;
} }
log.info('config', 'Found old user database file at %s', realPath);
log.info('config', 'Migrating user database to the new SQL database');
let migratableUsers: {
u: string;
c: number;
oo?: boolean;
}[];
try { try {
const data = await readFile(realPath, 'utf8'); const data = await readFile(realPath, 'utf8');
if (data.trim().length === 0) {
log.info('config', 'Old user database file is empty, nothing to migrate');
log.info(
'config',
'You SHOULD remove oidc.user_storage_file from your config!',
);
await rm(realPath, { force: true });
return;
}
const users = JSON.parse(data.trim()) as { const users = JSON.parse(data.trim()) as {
u?: string; u?: string;
c?: number; c?: number;
oo?: boolean; oo?: boolean;
}[]; }[];
// Never trust user input migratableUsers = users.filter(
return users.filter(
(user) => user.u !== undefined && user.c !== undefined, (user) => user.u !== undefined && user.c !== undefined,
) as { ) as {
u: string; u: string;
@@ -297,8 +287,38 @@ async function loadUserFile(path: string) {
oo?: boolean; oo?: boolean;
}[]; }[];
} catch (error) { } catch (error) {
log.debug('config', 'Error reading user database file: %s', error); log.warn('config', 'Error reading old user database file: %s', error);
log.debug('config', 'Using empty user database'); log.warn('config', 'Not migrating any users');
return []; return;
} }
if (migratableUsers.length === 0) {
log.info('config', 'No users found in the old database to migrate');
return;
}
log.info(
'config',
'Migrating %d users from the old database',
migratableUsers.length,
);
const updated = await db
.insert(users)
.values(
migratableUsers.map((user) => ({
id: ulid(),
sub: user.u,
caps: user.c,
onboarded: user.oo ?? false,
})),
)
.onConflictDoNothing({
target: users.sub,
})
.returning();
log.info('config', 'Migrated %d users successfully', updated.length);
log.info('config', 'Removed old user database file %s', realPath);
await rm(realPath, { force: true });
} }

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