mirror of
https://github.com/tale/headplane.git
synced 2026-07-26 15:58:14 +00:00
Compare commits
75 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 262c7bf82a | |||
| f8b23a5c89 | |||
| 775b81a7fa | |||
| 62817efa6e | |||
| b95d601ff6 | |||
| ea27c846e2 | |||
| 8c508e0602 | |||
| 0a51182eed | |||
| 22a521dff5 | |||
| de07372427 | |||
| 2584a4ef55 | |||
| 30b528c491 | |||
| d5acba88c7 | |||
| d7f1d665a4 | |||
| 7901f37002 | |||
| 7a62359b6a | |||
| 2e38a1d5e3 | |||
| 7221f1c2c3 | |||
| 56c5e5ac8c | |||
| 09be95c7bc | |||
| 0512565f8e | |||
| d4eee702e9 | |||
| fb4b0b1404 | |||
| 1e0ff7ead6 | |||
| c6b6cbc122 | |||
| deb284e2b4 | |||
| 5a2098eea5 | |||
| b961b339bb | |||
| ac6f9e4f7e | |||
| 3026b33834 | |||
| ecd284b5d8 | |||
| 4cf4e5c040 | |||
| 9e5e5a613a | |||
| 4d252833ef | |||
| 9238f69bfc | |||
| d110dd2bcb | |||
| addef55f30 | |||
| 67c6c0b453 | |||
| 418c3bc255 | |||
| 946921fff7 | |||
| e4030ed254 | |||
| fccd2eefc4 | |||
| 93be180479 | |||
| 6582f8ae07 | |||
| f0f02b3c4c | |||
| c030d1fbe4 | |||
| 724e454466 | |||
| dc80c93184 | |||
| c164e07336 | |||
| d26c23313c | |||
| d5f76637f5 | |||
| 272233ae62 | |||
| 974c3b0e48 | |||
| acd3ff3403 | |||
| 1961608e12 | |||
| c4ac28f90b | |||
| a0b2077c7b | |||
| 0f19fdf0da | |||
| d255098128 | |||
| 98e0806e5a | |||
| 44dffeaff0 | |||
| 33f7bbb0cf | |||
| 10278d0cc9 | |||
| bdcd4c5bad | |||
| 43cff2f4b7 | |||
| 61e7303363 | |||
| dc44cc4155 | |||
| 30ce5e2727 | |||
| 55a09476ab | |||
| ac90b4e8bb | |||
| 4b47b1bbed | |||
| 003985d192 | |||
| 1259642f8a | |||
| 4cd0c1e206 | |||
| b2dfa773b0 |
@@ -0,0 +1,84 @@
|
||||
name: Docs
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
branches:
|
||||
- "main"
|
||||
- "release/docs"
|
||||
|
||||
concurrency:
|
||||
group: docs-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
beta:
|
||||
name: Deploy Beta Docs
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build docs
|
||||
run: pnpm docs:build
|
||||
env:
|
||||
HEADPLANE_BETA_DOCS: "true"
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
command: pages deploy docs/.vitepress/dist --project-name=headplane-docs-beta
|
||||
|
||||
stable:
|
||||
name: Deploy Stable Docs
|
||||
if: >
|
||||
(startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-'))
|
||||
|| github.ref == 'refs/heads/release/docs'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check out the repo
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: package.json
|
||||
cache: pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build docs
|
||||
run: pnpm docs:build
|
||||
|
||||
- name: Deploy to Cloudflare Pages
|
||||
uses: cloudflare/wrangler-action@v3
|
||||
with:
|
||||
apiToken: ${{ secrets.CF_API_TOKEN }}
|
||||
accountId: ${{ secrets.CF_ACCOUNT_ID }}
|
||||
command: pages deploy docs/.vitepress/dist --project-name=headplane-docs
|
||||
@@ -13,3 +13,4 @@ node_modules
|
||||
/docs/.vitepress/dist/
|
||||
/docs/.vitepress/cache/
|
||||
/.direnv
|
||||
/vendor
|
||||
|
||||
+59
-23
@@ -1,31 +1,67 @@
|
||||
# 0.7.0-beta.1 (March 27, 2026)
|
||||
# 0.7.0-beta.4 (May 31, 2026)
|
||||
|
||||
> This is a beta release. Please report any issues you encounter.
|
||||
|
||||
- **Migrated all UI components from react-aria/react-stately to @base-ui-components/react.**
|
||||
- Removed `react-aria`, `react-stately`, and `tailwindcss-react-aria-components` as dependencies.
|
||||
- **Rearchitected the Headplane Agent** from a long-running stdin/stdout daemon to a one-shot sync model (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
|
||||
- The Go binary connects to the Tailnet, fetches all peer hostinfo as JSON, and exits.
|
||||
- The Node.js manager auto-generates ephemeral tag-only pre-auth keys (requires Headscale 0.28+).
|
||||
- Deprecated `integration.agent.pre_authkey` and `integration.agent.cache_path` config fields.
|
||||
- Added `integration.agent.executable_path` config field.
|
||||
- **Consolidated the Headscale API key** under `headscale.api_key` (and `headscale.api_key_path`).
|
||||
- Deprecated `oidc.headscale_api_key` — it is still read as a fallback but will be removed in a future release.
|
||||
- Both the agent and OIDC now use the same key from `headscale.api_key`.
|
||||
- **Reworked the authentication system** with a new `AuthService` that consolidates session management and role enforcement (via [#489](https://github.com/tale/headplane/pull/489)).
|
||||
- Added an agent status page at `/settings/agent` showing sync status, node count, errors, and a "Sync Now" button.
|
||||
- Added additional machine list filters for user, tag, status, and route (via [#507](https://github.com/tale/headplane/pull/507), closes [#506](https://github.com/tale/headplane/issues/506)).
|
||||
- **Headplane now requires Headscale 0.27.0 or newer.** Support for 0.26.x has been dropped. If `/version` returns 404 (the endpoint was added in 0.27.0), Headplane logs an error and keeps retrying so an in-place Headscale upgrade is picked up without a restart.
|
||||
- **Replaced the OpenAPI hash detection with `/version`.** Capabilities are now derived from the version reported by `/version` instead of fingerprinting the OpenAPI schema. This dramatically simplifies version detection and works with every supported release out of the box.
|
||||
- **Made Headscale boot resilient.** Headplane now boots even when Headscale is unreachable; capabilities default permissively and a background retry settles them once Headscale responds. No more cold-start ordering problems with docker-compose.
|
||||
- **Added optional in-process TLS termination.** Setting `server.tls_cert_path` and `server.tls_key_path` makes Headplane serve HTTPS/1.1 on `server.port` directly — no reverse proxy required. `server.cookie_secure` is auto-forced to `true` (with a warning) whenever TLS is enabled, since browsers refuse `Secure`-less cookies over HTTPS. HTTP/2 and HTTP/3 are intentionally not supported in-process; terminate those at a reverse proxy if you need them (closes [#403](https://github.com/tale/headplane/issues/403)).
|
||||
- **Made the bundled Docker healthcheck zero-config across HTTP and HTTPS.** Headplane writes its loopback URL (scheme, port, and basename included) to `/tmp/headplane-listen` on startup, and `hp_healthcheck` reads that file and probes the URL verbatim. Enabling TLS or changing `server.port` no longer requires any healthcheck-specific configuration. Native installs are unaffected — the listen file is only written when `HEADPLANE_LISTEN_FILE` is set, which the Dockerfile does automatically.
|
||||
- Added Rename and Delete actions for unlinked Headscale users on the Users page so admins can manage Headscale users that have no Headplane account (closes [#525](https://github.com/tale/headplane/issues/525)).
|
||||
- Documented [Custom Certificate Authorities](/configuration/tls#custom-certificate-authorities) for trusting private or self-signed CAs across every outbound TLS connection (OIDC, Headscale, Docker, etc.) via Node's `NODE_EXTRA_CA_CERTS`. This replaces the previous workaround of rebuilding the Docker image to extend the system trust store (closes [#313](https://github.com/tale/headplane/issues/313)).
|
||||
- Fixed user-management actions (link, change role, transfer ownership) using the wrong ID type for unlinked Headplane users. Form fields are now explicitly `headplane_user_id` vs. `headscale_user_id`, and the auth layer no longer round-trips through Headscale to recover the OIDC subject.
|
||||
- Fixed the "Register Machine Key" dialog passing the Headscale numeric user id instead of the username. Headscale's `RegisterNodeRequest.user` proto field is a `string` looked up via `GetUserByName` (no numeric fallback), so registration was failing whenever the selected owner's display name differed from their numeric id (closes [#532](https://github.com/tale/headplane/issues/532)).
|
||||
- Fixed pre-auth key expiration on Headscale 0.27.x. The pre-0.28 expire endpoint takes a `uint64 user` field which the API layer reads from `key.user?.id`, but the caller was wrapping the id as `{ name: user }`, causing the request to send an empty user field. Headplane now correctly passes the numeric Headscale user id.
|
||||
- Fixed dialog panels growing beyond the viewport; dialog content is now constrained and scrollable (via [#556](https://github.com/tale/headplane/pull/556)).
|
||||
- Fixed focus rings on inputs and buttons inside dialogs being clipped by the scrollable content container.
|
||||
- Fixed tooltips on the last row of the machines table being clipped by the viewport; tooltips now anchor above the trigger with collision padding (closes [#508](https://github.com/tale/headplane/issues/508)).
|
||||
- Corrected the Docker healthcheck example in the docs to use the required `CMD` prefix so reverse proxies don't see the container as unhealthy (closes [#535](https://github.com/tale/headplane/issues/535)).
|
||||
|
||||
---
|
||||
|
||||
# 0.7.0-beta.3 (May 14, 2026)
|
||||
|
||||
> This is a beta release. Please report any issues you encounter.
|
||||
|
||||
- Fixed GHSA-vgj6-hcf2-fqf6, a path traversal / RBAC bypass in Headscale node and user rename API calls.
|
||||
|
||||
---
|
||||
|
||||
# 0.6.3 (May 14, 2026)
|
||||
|
||||
- Fixed GHSA-vgj6-hcf2-fqf6, a path traversal / RBAC bypass in Headscale node and user rename API calls.
|
||||
|
||||
---
|
||||
|
||||
# 0.7.0-beta.2 (April 9, 2026)
|
||||
|
||||
> This is a beta release. Please report any issues you encounter.
|
||||
|
||||
- **Rebuilt the user model to enable "account linking" between Headplane and Headscale.** OIDC users are automatically linked to their Headscale counterparts based on subject and email. Users who cannot be automatically linked can claim an unlinked Headscale user during onboarding. See the [SSO docs](/features/sso) for details (via [#489](https://github.com/tale/headplane/pull/489)).
|
||||
- **Rebuilt Browser SSH** with a new terminal powered by [Ghostty WASM](https://restty.dev), improved session handling, and support for custom DERP ports. See the [Browser SSH docs](/features/ssh) for details (closes [#515](https://github.com/tale/headplane/issues/515), closes [#386](https://github.com/tale/headplane/issues/386)).
|
||||
- **Rearchitected the Headplane Agent** with a periodic sync model and extensive caching. The agent now auto-generates ephemeral pre-auth keys (requires Headscale 0.28+). See the [Agent docs](/features/agent) for details (closes [#350](https://github.com/tale/headplane/issues/350), closes [#455](https://github.com/tale/headplane/issues/455)).
|
||||
- **Replaced `openid-client` with a new OIDC implementation.** Fixes `client_secret_basic` not working with Google SSO and other providers (closes [#493](https://github.com/tale/headplane/issues/493), closes [#516](https://github.com/tale/headplane/issues/516)).
|
||||
- **Migrated all UI components from react-aria to [Base UI](https://base-ui.com).**
|
||||
- **Consolidated the Headscale API key** under `headscale.api_key` (and `headscale.api_key_path`). Deprecated `oidc.headscale_api_key` — it is still read as a fallback but will be removed in a future release.
|
||||
- Added machine list filters for user, tag, status, and route (via [#507](https://github.com/tale/headplane/pull/507), closes [#506](https://github.com/tale/headplane/issues/506)).
|
||||
- Added self-service pre-auth key creation for auditor role users (via [#478](https://github.com/tale/headplane/pull/478), closes [#453](https://github.com/tale/headplane/issues/453)).
|
||||
- Fetch OIDC profile pictures server-side when the URL requires authentication (via [#510](https://github.com/tale/headplane/pull/510), closes [#326](https://github.com/tale/headplane/issues/326)).
|
||||
- Fixed first user not being assigned the owner role on OIDC login (via [#480](https://github.com/tale/headplane/pull/480), closes [#266](https://github.com/tale/headplane/issues/266)).
|
||||
- Fixed login errors throwing an unexpected server error instead of showing form validation (via [#475](https://github.com/tale/headplane/pull/475), closes [#474](https://github.com/tale/headplane/issues/474)).
|
||||
- Fixed agent HostInfo not refreshing periodically using `cache_ttl` (via [#477](https://github.com/tale/headplane/pull/477), closes [#427](https://github.com/tale/headplane/issues/427)).
|
||||
- Fixed a race condition where the SSE controller could be used after being closed.
|
||||
- Fixed WebSSH WASM prefix paths for correct asset loading (closes [#386](https://github.com/tale/headplane/issues/386)).
|
||||
- Fixed Dockerfile WASM copy paths.
|
||||
- Fixed CodeMirror version mismatch override in the ACL editor.
|
||||
- Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)).
|
||||
- Added an agent status page at `/settings/agent` showing sync status, node count, and errors.
|
||||
- Added local endpoint and address information to the machine detail page.
|
||||
- Improved the ACL editor appearance and fixed a CodeMirror version mismatch.
|
||||
- Store OIDC profile pictures in the database to prevent cookie overflow (via [#510](https://github.com/tale/headplane/pull/510), closes [#326](https://github.com/tale/headplane/issues/326)).
|
||||
- Detect unsupported Docker API versions early with a clear error message (via [#497](https://github.com/tale/headplane/pull/497)).
|
||||
- Fixed "No expiry" badge not displaying for nodes with zero-time expiry values (via [#527](https://github.com/tale/headplane/pull/527), closes [#526](https://github.com/tale/headplane/issues/526)).
|
||||
- Fixed first user not being assigned the owner role on OIDC login (via [#480](https://github.com/tale/headplane/pull/480), closes [#266](https://github.com/tale/headplane/issues/266)).
|
||||
- Fixed login errors throwing a server error instead of showing form validation (via [#475](https://github.com/tale/headplane/pull/475), closes [#474](https://github.com/tale/headplane/issues/474)).
|
||||
- Fixed pre-auth key expiration on Headscale 0.28+ (closes [#519](https://github.com/tale/headplane/issues/519)).
|
||||
- Fixed OIDC subject matching for providers with special characters in user IDs, e.g. Auth0 (closes [#428](https://github.com/tale/headplane/issues/428)).
|
||||
- Fixed `headscale.api_key` not being used consistently across all code paths.
|
||||
- Fixed agent HostInfo not refreshing periodically using `cache_ttl` (via [#477](https://github.com/tale/headplane/pull/477), closes [#427](https://github.com/tale/headplane/issues/427)).
|
||||
- Fixed agent working directory being wiped on restart.
|
||||
- Fixed a race condition where the SSE controller could be used after being closed.
|
||||
- Fixed cookie secret generation using incorrect byte length (via [#501](https://github.com/tale/headplane/pull/501)).
|
||||
- Fixed OIDC configuration error troubleshooting link (via [#518](https://github.com/tale/headplane/pull/518), closes [#517](https://github.com/tale/headplane/issues/517)).
|
||||
- Fixed deprecated Nix package attributes (via [#521](https://github.com/tale/headplane/pull/521)).
|
||||
- Updated NixOS module options: removed deprecated agent fields, added `headscale.api_key_path` and `integration.agent.executable_path`.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25.1 AS go-base
|
||||
WORKDIR /run
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends patch && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY go.mod go.sum build.sh ./
|
||||
COPY patches/ ./patches/
|
||||
RUN go mod download
|
||||
|
||||
COPY cmd/ ./cmd/
|
||||
@@ -55,6 +57,11 @@ COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD ["/bin/hp_healthcheck"]
|
||||
|
||||
# Tells Headplane to publish its loopback healthcheck URL to this
|
||||
# file on startup; `hp_healthcheck` reads it. Docker-only — native
|
||||
# installs don't ship a consumer.
|
||||
ENV HEADPLANE_LISTEN_FILE=/tmp/headplane-listen
|
||||
|
||||
WORKDIR /app
|
||||
CMD [ "/app/build/server/index.js" ]
|
||||
|
||||
@@ -71,5 +78,7 @@ COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
||||
CMD ["/bin/hp_healthcheck"]
|
||||
|
||||
ENV HEADPLANE_LISTEN_FILE=/tmp/headplane-listen
|
||||
|
||||
WORKDIR /app
|
||||
CMD [ "node", "/app/build/server/index.js" ]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# Headplane
|
||||
|
||||
> A feature-complete web UI for [Headscale](https://headscale.net)
|
||||
|
||||
<picture>
|
||||
@@ -32,14 +33,17 @@ These are some of the features that Headplane offers:
|
||||
- Configurability for Headscale's settings
|
||||
|
||||
## Deployment
|
||||
|
||||
Refer to the [website](https://headplane.net) for detailed installation instructions.
|
||||
|
||||
## Versioning
|
||||
|
||||
Headplane uses [semantic versioning](https://semver.org/) for its releases (since v0.6.0).
|
||||
Pre-release builds are available under the `next` tag and get updated when a new release
|
||||
PR is opened and actively in testing.
|
||||
|
||||
## Contributing
|
||||
|
||||
Headplane is an open-source project and contributions are welcome! If you have
|
||||
any suggestions, bug reports, or feature requests, please open an issue. Also
|
||||
refer to the [contributor guidelines](./docs/CONTRIBUTING.md) for more info.
|
||||
|
||||
@@ -14,18 +14,18 @@ export interface AttributeProps {
|
||||
|
||||
export default function Attribute({ name, value, tooltip, isCopyable }: AttributeProps) {
|
||||
return (
|
||||
<dl className="flex items-center gap-1 text-sm">
|
||||
<dl className="group/attr flex items-baseline gap-1 text-sm">
|
||||
<dt
|
||||
className={cn(
|
||||
"w-1/3 sm:w-1/4 lg:w-1/3 shrink-0 min-w-0",
|
||||
"text-mist-500 dark:text-mist-400",
|
||||
tooltip ? "flex items-center gap-1" : undefined,
|
||||
"text-mist-600 dark:text-mist-300",
|
||||
tooltip ? "flex items-baseline gap-1" : undefined,
|
||||
)}
|
||||
>
|
||||
{name}
|
||||
{tooltip ? (
|
||||
<Tooltip content={tooltip}>
|
||||
<Info className="size-4" />
|
||||
<Info className="size-3.5 translate-y-0.5 opacity-40 transition-opacity hover:opacity-100" />
|
||||
</Tooltip>
|
||||
) : undefined}
|
||||
</dt>
|
||||
@@ -64,16 +64,22 @@ export default function Attribute({ name, value, tooltip, isCopyable }: Attribut
|
||||
<div suppressHydrationWarning className="truncate">
|
||||
{value}
|
||||
</div>
|
||||
{isCopyable ? (
|
||||
<div>
|
||||
<Check className="hidden size-4 data-copied:block" />
|
||||
<Copy className="block size-4 data-copied:hidden" />
|
||||
</div>
|
||||
) : undefined}
|
||||
<div className="opacity-0 transition-opacity group-hover/attr:opacity-100">
|
||||
<Check className="hidden size-3.5 data-copied:block" />
|
||||
<Copy className="block size-3.5 data-copied:hidden" />
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="relative min-w-0 truncate" suppressHydrationWarning>
|
||||
{value}
|
||||
<div className="relative min-w-0" suppressHydrationWarning>
|
||||
{value.includes("\n") ? (
|
||||
value.split("\n").map((line) => (
|
||||
<div key={line} className="truncate">
|
||||
{line}
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="truncate">{value}</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</dd>
|
||||
|
||||
@@ -58,7 +58,8 @@ function Panel(props: DialogPanelProps) {
|
||||
return (
|
||||
<AlertDialog.Popup
|
||||
className={cn(
|
||||
"w-full max-w-lg rounded-xl p-4",
|
||||
"flex w-full max-w-lg flex-col rounded-xl p-4",
|
||||
"max-h-[90dvh]",
|
||||
"outline-hidden",
|
||||
"bg-white dark:bg-mist-900",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
@@ -67,6 +68,7 @@ function Panel(props: DialogPanelProps) {
|
||||
>
|
||||
<Form
|
||||
method={method ?? "POST"}
|
||||
className="flex min-h-0 flex-1 flex-col"
|
||||
onSubmit={(event) => {
|
||||
if (onSubmit) {
|
||||
onSubmit(event);
|
||||
@@ -77,8 +79,13 @@ function Panel(props: DialogPanelProps) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4">{children}</div>
|
||||
<div className="mt-5 flex justify-end gap-3">
|
||||
{/* px-1 -mx-1 gives focus rings on inputs/buttons room to render
|
||||
without being clipped by overflow-y-auto (which implicitly forces
|
||||
overflow-x: auto per CSS spec). */}
|
||||
<div className="-mx-1 flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-1">
|
||||
{children}
|
||||
</div>
|
||||
<div className="mt-5 flex shrink-0 justify-end gap-3">
|
||||
{variant === "unactionable" ? (
|
||||
<AlertDialog.Close render={<Button>Close</Button>} />
|
||||
) : (
|
||||
|
||||
@@ -29,7 +29,7 @@ function TabList({ children, className }: { children: ReactNode; className?: str
|
||||
return (
|
||||
<BaseTabs.List
|
||||
className={cn(
|
||||
"flex items-center rounded-t-lg w-fit max-w-full",
|
||||
"flex items-center rounded-t-md w-fit max-w-full",
|
||||
"border-mist-200 dark:border-mist-800",
|
||||
"border-t border-x",
|
||||
className,
|
||||
@@ -56,7 +56,7 @@ function Tab({
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1 z-10",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
"border-r border-mist-200 dark:border-mist-800",
|
||||
"first:rounded-tl-lg last:rounded-tr-lg last:border-r-0",
|
||||
"first:rounded-tl-md last:rounded-tr-md last:border-r-0",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
@@ -76,7 +76,7 @@ function Panel({
|
||||
value={value}
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full overflow-clip rounded-b-lg rounded-r-lg",
|
||||
"w-full overflow-clip rounded-b-md rounded-r-md",
|
||||
"border border-mist-200 dark:border-mist-800",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function Tooltip({ children, content, className }: TooltipProps)
|
||||
{children}
|
||||
</BaseTooltip.Trigger>
|
||||
<BaseTooltip.Portal>
|
||||
<BaseTooltip.Positioner sideOffset={4}>
|
||||
<BaseTooltip.Positioner side="top" sideOffset={4} collisionPadding={8}>
|
||||
<BaseTooltip.Popup
|
||||
className={cn(
|
||||
"z-50 rounded-lg p-3 text-sm w-48",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { StrictMode, startTransition } from 'react';
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
import { HydratedRouter } from 'react-router/dom';
|
||||
import { StrictMode, startTransition } from "react";
|
||||
import { hydrateRoot } from "react-dom/client";
|
||||
import { HydratedRouter } from "react-router/dom";
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>,
|
||||
);
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||
import type { AppLoadContext, EntryContext } from "react-router";
|
||||
import { PassThrough } from "node:stream";
|
||||
|
||||
import { createReadableStreamFromReadable } from "@react-router/node";
|
||||
import { isbot } from "isbot";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { RenderToPipeableStreamOptions } from "react-dom/server";
|
||||
import { renderToPipeableStream } from "react-dom/server";
|
||||
import type { AppLoadContext, EntryContext } from "react-router";
|
||||
import { ServerRouter } from "react-router";
|
||||
|
||||
export const streamTimeout = 5_000;
|
||||
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
// Globals replaced at build time by Vite (`define` in `vite.config.ts`).
|
||||
|
||||
declare const __PREFIX__: string;
|
||||
declare const __VERSION__: string;
|
||||
+4
-9
@@ -2,7 +2,6 @@ import { Outlet, redirect, type ShouldRevalidateFunction } from "react-router";
|
||||
|
||||
import { ErrorBanner } from "~/components/error-banner";
|
||||
import StatusBanner from "~/components/status-banner";
|
||||
import { pruneEphemeralNodes } from "~/server/db/pruner";
|
||||
import { isDataUnauthorizedError } from "~/server/headscale/api/error-client";
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
@@ -30,12 +29,9 @@ export const shouldRevalidate: ShouldRevalidateFunction = ({
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
try {
|
||||
const principal = await context.auth.require(request);
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { principal, api } = await context.apiForRequest(request);
|
||||
|
||||
const user =
|
||||
principal.kind === "oidc"
|
||||
@@ -49,11 +45,10 @@ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
|
||||
: { name: principal.displayName, subject: "api_key" };
|
||||
|
||||
// MARK: The session should stay valid if Headscale isn't healthy
|
||||
const isHealthy = await api.isHealthy();
|
||||
const isHealthy = await context.headscale.health();
|
||||
if (isHealthy) {
|
||||
try {
|
||||
await api.getApiKeys();
|
||||
await pruneEphemeralNodes({ context, request, ...rest });
|
||||
await api.apiKeys.list();
|
||||
} catch (error) {
|
||||
if (isDataUnauthorizedError(error)) {
|
||||
const displayName =
|
||||
|
||||
+87
-7
@@ -1,5 +1,17 @@
|
||||
import { CircleQuestionMark, CircleUser, Globe, Lock, Server, Settings, Users } from "lucide-react";
|
||||
import { NavLink, useSubmit } from "react-router";
|
||||
import {
|
||||
Check,
|
||||
CircleQuestionMark,
|
||||
CircleUser,
|
||||
Globe,
|
||||
Lock,
|
||||
Monitor,
|
||||
Moon,
|
||||
Server,
|
||||
Settings,
|
||||
Sun,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { NavLink, unstable_useRoute as useRoute, useLocation, useSubmit } from "react-router";
|
||||
|
||||
import Link from "~/components/link";
|
||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
|
||||
@@ -7,6 +19,7 @@ import logoBg from "~/logo/dark-bg.svg";
|
||||
import logoDark from "~/logo/dark.svg";
|
||||
import logoLight from "~/logo/light.svg";
|
||||
import cn from "~/utils/cn";
|
||||
import type { ColorScheme } from "~/utils/color-scheme";
|
||||
|
||||
export interface HeaderProps {
|
||||
user: {
|
||||
@@ -35,9 +48,26 @@ const tabs = [
|
||||
{ to: "/settings", icon: Settings, label: "Settings", key: "settings" },
|
||||
] as const;
|
||||
|
||||
const colorSchemes = [
|
||||
{ value: "system", label: "System", icon: Monitor },
|
||||
{ value: "light", label: "Light", icon: Sun },
|
||||
{ value: "dark", label: "Dark", icon: Moon },
|
||||
] as const satisfies ReadonlyArray<{
|
||||
value: ColorScheme;
|
||||
label: string;
|
||||
icon: typeof Monitor;
|
||||
}>;
|
||||
|
||||
export default function Header({ user, access, configAvailable }: HeaderProps) {
|
||||
const submit = useSubmit();
|
||||
const showTabs = access.ui;
|
||||
const rootRoute = useRoute("root");
|
||||
const currentColorScheme: ColorScheme = rootRoute?.loaderData?.colorScheme ?? "system";
|
||||
// useLocation returns the path with the basename already stripped, which is
|
||||
// what `redirect()` expects — react-router re-applies the basename when
|
||||
// following the redirect on the client.
|
||||
const location = useLocation();
|
||||
const returnTo = location.pathname + location.search;
|
||||
|
||||
return (
|
||||
<header
|
||||
@@ -46,10 +76,10 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
|
||||
"dark:border-b dark:border-mist-800 shadow-inner",
|
||||
)}
|
||||
>
|
||||
<div className="container flex items-center justify-between py-4">
|
||||
<div className="flex items-center gap-x-8">
|
||||
<div className="container flex items-center gap-x-4 py-4">
|
||||
<div className="flex min-w-0 items-center gap-x-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<picture>
|
||||
<picture className="min-w-8">
|
||||
<source srcSet={logoLight} media="(prefers-color-scheme: dark)" />
|
||||
<source srcSet={logoDark} media="(prefers-color-scheme: light)" />
|
||||
<img src={logoBg} alt="Headplane logo" />
|
||||
@@ -57,7 +87,7 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
|
||||
<h1 className="text-2xl font-semibold">headplane</h1>
|
||||
</div>
|
||||
{showTabs && (
|
||||
<nav className="hidden items-center gap-x-2 text-sm font-medium md:flex">
|
||||
<nav className="hidden items-center gap-x-2 overflow-x-auto p-1 text-sm font-medium md:flex">
|
||||
{tabs.map((tab) => {
|
||||
if (!access[tab.key]) return null;
|
||||
if ((tab.key === "dns" || tab.key === "settings") && !configAvailable) return null;
|
||||
@@ -87,7 +117,7 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
|
||||
</nav>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4">
|
||||
<div className="ml-auto grid shrink-0 grid-cols-2 gap-x-4">
|
||||
<Menu>
|
||||
<MenuTrigger className="size-8 rounded-full p-1">
|
||||
<CircleQuestionMark className="w-5" />
|
||||
@@ -135,6 +165,24 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
|
||||
</div>
|
||||
</MenuItem>
|
||||
<MenuSeparator />
|
||||
{colorSchemes.map(({ value, label, icon: Icon }) => (
|
||||
<MenuItem
|
||||
key={value}
|
||||
onClick={() =>
|
||||
submit(
|
||||
{ colorScheme: value, returnTo },
|
||||
{ action: "/api/color-scheme", method: "POST" },
|
||||
)
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Icon className="size-4" />
|
||||
<span className="flex-1">{label}</span>
|
||||
{currentColorScheme === value && <Check className="size-4" />}
|
||||
</div>
|
||||
</MenuItem>
|
||||
))}
|
||||
<MenuSeparator />
|
||||
<MenuItem
|
||||
variant="danger"
|
||||
onClick={() => submit({}, { action: "/logout", method: "POST" })}
|
||||
@@ -145,6 +193,38 @@ export default function Header({ user, access, configAvailable }: HeaderProps) {
|
||||
</Menu>
|
||||
</div>
|
||||
</div>
|
||||
{showTabs && (
|
||||
<div className="block overflow-x-auto p-2 md:hidden">
|
||||
<nav className="flex items-center gap-x-2 text-sm font-medium">
|
||||
{tabs.map((tab) => {
|
||||
if (!access[tab.key]) return null;
|
||||
if ((tab.key === "dns" || tab.key === "settings") && !configAvailable) return null;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={tab.to}
|
||||
className={({ isActive }) =>
|
||||
cn(
|
||||
"relative px-3 py-1.5 flex items-center gap-x-1.5 rounded-md text-nowrap",
|
||||
"hover:bg-mist-300/50 dark:hover:bg-mist-800",
|
||||
"focus:outline-hidden focus:ring-2 focus:ring-indigo-500/40 focus:ring-offset-1",
|
||||
"dark:focus:ring-indigo-400/40 dark:focus:ring-offset-mist-900",
|
||||
"text-mist-600 dark:text-mist-300",
|
||||
isActive &&
|
||||
"text-mist-900 dark:text-mist-50 after:content-[''] after:absolute after:-bottom-2 after:inset-x-1 after:h-0.5 after:rounded-full after:bg-indigo-500",
|
||||
)
|
||||
}
|
||||
prefetch="intent"
|
||||
to={tab.to}
|
||||
>
|
||||
<tab.icon className="w-4" />
|
||||
{tab.label}
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"0.26.1": ["0.26.0", "0.26.1"],
|
||||
"0.27.0": ["0.27.0"],
|
||||
"0.27.1": ["0.27.1"],
|
||||
"0.28.0": ["0.28.0"]
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
{
|
||||
"0.26.0": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.26.1": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.27.0": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.27.1": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
},
|
||||
"0.28.0": {
|
||||
"GET /api/v1/apikey": "efe31b6dc980e158",
|
||||
"POST /api/v1/apikey": "39953a96c1da5312",
|
||||
"POST /api/v1/apikey/expire": "ca56add866802f17",
|
||||
"DELETE /api/v1/apikey/{prefix}": "b10ca7d2750405b2",
|
||||
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
|
||||
"GET /api/v1/health": "5e447272e72b2e5f",
|
||||
"GET /api/v1/node": "8bb18b8c7cfb4f20",
|
||||
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
|
||||
"POST /api/v1/node/register": "539f7cb3a84d43d4",
|
||||
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
|
||||
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
|
||||
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
|
||||
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
|
||||
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
|
||||
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
|
||||
"GET /api/v1/policy": "d6c639be304cd3c0",
|
||||
"PUT /api/v1/policy": "6cbe80bde771a388",
|
||||
"GET /api/v1/preauthkey": "8428b44e3a821e9e",
|
||||
"DELETE /api/v1/preauthkey": "f05ea1bc8ad89a09",
|
||||
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
|
||||
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
|
||||
"GET /api/v1/user": "228831b58ccc5a17",
|
||||
"POST /api/v1/user": "a4e1d889d7962da5",
|
||||
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
|
||||
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
|
||||
}
|
||||
}
|
||||
+29
-9
@@ -1,7 +1,12 @@
|
||||
import type { LinksFunction, MetaFunction } from "react-router";
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "react-router";
|
||||
import "@fontsource-variable/inter";
|
||||
import { ExternalScripts } from "remix-utils/external-scripts";
|
||||
import type { MetaFunction } from "react-router";
|
||||
import {
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
unstable_useRoute as useRoute,
|
||||
} from "react-router";
|
||||
|
||||
import { LiveDataProvider } from "~/utils/live-data";
|
||||
import ToastProvider from "~/utils/toast-provider";
|
||||
@@ -9,7 +14,9 @@ import ToastProvider from "~/utils/toast-provider";
|
||||
import type { Route } from "./+types/root";
|
||||
import { ErrorBanner } from "./components/error-banner";
|
||||
|
||||
import stylesheet from "~/tailwind.css?url";
|
||||
import "@fontsource-variable/inter/opsz.css";
|
||||
import "./tailwind.css";
|
||||
import { getColorScheme } from "./utils/color-scheme";
|
||||
|
||||
export const meta: MetaFunction = () => [
|
||||
{ title: "Headplane" },
|
||||
@@ -19,15 +26,29 @@ export const meta: MetaFunction = () => [
|
||||
},
|
||||
];
|
||||
|
||||
export const links: LinksFunction = () => [{ rel: "stylesheet", href: stylesheet }];
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const colorScheme = await getColorScheme(request);
|
||||
return { colorScheme };
|
||||
}
|
||||
|
||||
export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
const { loaderData } = useRoute("root");
|
||||
|
||||
// LiveDataProvider is wrapped at the top level since dialogs and things
|
||||
// that control its state are usually open in portal containers which
|
||||
// are not a part of the normal React tree.
|
||||
return (
|
||||
<LiveDataProvider>
|
||||
<html lang="en">
|
||||
<html
|
||||
lang="en"
|
||||
className={
|
||||
loaderData?.colorScheme === "dark"
|
||||
? "dark"
|
||||
: loaderData?.colorScheme === "light"
|
||||
? "light"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport" />
|
||||
@@ -35,12 +56,11 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
|
||||
<Links />
|
||||
<link href={`${__PREFIX__}/favicon.ico`} rel="icon" />
|
||||
</head>
|
||||
<body className="overflow-x-hidden overscroll-none dark:bg-mist-900 dark:text-mist-50">
|
||||
<body className="w-full overflow-x-hidden overscroll-none dark:bg-mist-900 dark:text-mist-50">
|
||||
{children}
|
||||
<ToastProvider />
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
<ExternalScripts />
|
||||
</body>
|
||||
</html>
|
||||
</LiveDataProvider>
|
||||
|
||||
+5
-2
@@ -5,7 +5,10 @@ export default [
|
||||
route("/healthz", "routes/util/healthz.ts"),
|
||||
|
||||
// API Routes
|
||||
...prefix("/api", [route("/info", "routes/util/info.ts")]),
|
||||
...prefix("/api", [
|
||||
route("/info", "routes/util/info.ts"),
|
||||
route("/color-scheme", "routes/util/color-scheme.ts"),
|
||||
]),
|
||||
...prefix("/events", [route("/live", "routes/util/live.ts")]),
|
||||
|
||||
// Authentication Routes
|
||||
@@ -13,7 +16,7 @@ export default [
|
||||
route("/logout", "routes/auth/logout.ts"),
|
||||
route("/oidc/callback", "routes/auth/oidc-callback.ts"),
|
||||
route("/oidc/start", "routes/auth/oidc-start.ts"),
|
||||
route("/ssh", "routes/ssh/console.tsx"),
|
||||
route("/ssh/:id", "routes/ssh/page.tsx"),
|
||||
|
||||
// All the main logged-in routes
|
||||
layout("layout/app.tsx", [
|
||||
|
||||
@@ -26,10 +26,9 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.setPolicy(policyData);
|
||||
const { policy, updatedAt } = await api.policy.set(policyData);
|
||||
return data({
|
||||
success: true,
|
||||
error: undefined,
|
||||
@@ -55,71 +54,29 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
|
||||
// we need to reference other error messages based on API version.
|
||||
if (context.hsApi.clientHelpers.isAtleast("0.27.0")) {
|
||||
if (message.includes("parsing HuJSON:")) {
|
||||
const cutIndex = message.indexOf("parsing HuJSON:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
|
||||
// Policy parse errors carry one of two prefixes (HuJSON syntax vs.
|
||||
// structural unmarshal). Headscale 0.27.0+ uses these forms; older
|
||||
// releases are no longer supported.
|
||||
if (message.includes("parsing HuJSON:")) {
|
||||
const cutIndex = message.indexOf("parsing HuJSON:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 16).trim()}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
return data(
|
||||
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes("parsing policy from bytes:")) {
|
||||
const cutIndex = message.indexOf("parsing policy from bytes:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
|
||||
if (message.includes("parsing policy from bytes:")) {
|
||||
const cutIndex = message.indexOf("parsing policy from bytes:");
|
||||
const trimmed =
|
||||
cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 26).trim()}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Pre-0.27.0 error messages
|
||||
if (message.includes("parsing hujson")) {
|
||||
const cutIndex = message.indexOf("err: hujson:");
|
||||
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 12)}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
if (message.includes("unmarshalling policy")) {
|
||||
const cutIndex = message.indexOf("err:");
|
||||
const trimmed = cutIndex > -1 ? `Syntax error: ${message.slice(cutIndex + 5)}` : message;
|
||||
|
||||
return data(
|
||||
{
|
||||
success: false,
|
||||
error: trimmed,
|
||||
policy: undefined,
|
||||
updatedAt: undefined,
|
||||
},
|
||||
400,
|
||||
);
|
||||
}
|
||||
return data(
|
||||
{ success: false, error: trimmed, policy: undefined, updatedAt: undefined },
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,17 +29,19 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
|
||||
};
|
||||
|
||||
// Try to load the ACL policy from the API.
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
try {
|
||||
const { policy, updatedAt } = await api.getPolicy();
|
||||
const { policy, updatedAt } = await api.policy.get();
|
||||
flags.writable = updatedAt !== null;
|
||||
flags.policy = policy;
|
||||
return flags;
|
||||
} catch (error) {
|
||||
if (isDataWithApiError(error)) {
|
||||
// Headscale returns "acl policy not found" when the policy mode is
|
||||
// set to file but no file exists, and returns a 500 when database
|
||||
// mode is used but the policies table is empty.
|
||||
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
|
||||
if (error.data.rawData.includes("acl policy not found")) {
|
||||
if (error.data.rawData.includes("acl policy not found") || error.data.statusCode === 500) {
|
||||
flags.policy = "";
|
||||
flags.writable = true;
|
||||
return flags;
|
||||
|
||||
@@ -1,112 +1,73 @@
|
||||
import * as shopify from '@shopify/lang-jsonc';
|
||||
import { xcodeDark, xcodeLight } from '@uiw/codemirror-theme-xcode';
|
||||
import CodeMirror from '@uiw/react-codemirror';
|
||||
import { BookCopy, CircleX } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import Merge from 'react-codemirror-merge';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import Fallback from './fallback';
|
||||
import * as shopify from "@shopify/lang-jsonc";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import { BookCopy, CircleX } from "lucide-react";
|
||||
import Merge from "react-codemirror-merge";
|
||||
import { ErrorBoundary } from "react-error-boundary";
|
||||
|
||||
import { headplaneTheme } from "./theme";
|
||||
|
||||
interface EditorProps {
|
||||
isDisabled?: boolean;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
isDisabled?: boolean;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
// TODO: Remove ClientOnly
|
||||
export function Editor(props: EditorProps) {
|
||||
const [light, setLight] = useState(false);
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)');
|
||||
setLight(theme.matches);
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="overflow-y-scroll h-editor text-sm">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<CircleX />
|
||||
<p className="text-lg font-semibold">Failed to load the editor.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ClientOnly fallback={<Fallback acl={props.value} />}>
|
||||
{() => (
|
||||
<CodeMirror
|
||||
editable={!props.isDisabled}
|
||||
extensions={[shopify.jsonc()]} // Allow editing unless disabled
|
||||
height="100%" // Use readOnly if disabled
|
||||
onChange={(value) => props.onChange(value)}
|
||||
readOnly={props.isDisabled}
|
||||
style={{ height: '100%' }}
|
||||
theme={light ? xcodeLight : xcodeDark}
|
||||
value={props.value}
|
||||
/>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="text-sm">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<CircleX />
|
||||
<p className="text-lg font-semibold">Failed to load the editor.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<CodeMirror
|
||||
editable={!props.isDisabled}
|
||||
extensions={[shopify.jsonc()]}
|
||||
minHeight="24rem"
|
||||
maxHeight="var(--height-editor)"
|
||||
onChange={(value) => props.onChange(value)}
|
||||
readOnly={props.isDisabled}
|
||||
theme={headplaneTheme}
|
||||
value={props.value}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface DifferProps {
|
||||
left: string;
|
||||
right: string;
|
||||
left: string;
|
||||
right: string;
|
||||
}
|
||||
|
||||
export function Differ(props: DifferProps) {
|
||||
const [light, setLight] = useState(false);
|
||||
useEffect(() => {
|
||||
const theme = window.matchMedia('(prefers-color-scheme: light)');
|
||||
setLight(theme.matches);
|
||||
theme.addEventListener('change', (theme) => {
|
||||
setLight(theme.matches);
|
||||
});
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="text-sm">
|
||||
{props.left === props.right ? (
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<BookCopy />
|
||||
<p className="text-lg font-semibold">No changes</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-editor overflow-y-scroll">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<CircleX />
|
||||
<p className="text-lg font-semibold">
|
||||
Failed to load the editor.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ClientOnly fallback={<Fallback acl={props.right} />}>
|
||||
{() => (
|
||||
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
|
||||
<Merge.Original
|
||||
extensions={[shopify.jsonc()]}
|
||||
readOnly
|
||||
value={props.left}
|
||||
/>
|
||||
<Merge.Modified
|
||||
extensions={[shopify.jsonc()]}
|
||||
readOnly
|
||||
value={props.right}
|
||||
/>
|
||||
</Merge>
|
||||
)}
|
||||
</ClientOnly>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div className="text-sm">
|
||||
{props.left === props.right ? (
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<BookCopy />
|
||||
<p className="text-lg font-semibold">No changes</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-editor">
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
<div className="flex flex-col items-center gap-2.5 py-8">
|
||||
<CircleX />
|
||||
<p className="text-lg font-semibold">Failed to load the editor.</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Merge orientation="a-b" theme={headplaneTheme}>
|
||||
<Merge.Original extensions={[shopify.jsonc()]} readOnly value={props.left} />
|
||||
<Merge.Modified extensions={[shopify.jsonc()]} readOnly value={props.right} />
|
||||
</Merge>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,36 +1,18 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface Props {
|
||||
readonly acl: string;
|
||||
}
|
||||
|
||||
export default function Fallback({ acl }: Props) {
|
||||
export default function Fallback() {
|
||||
return (
|
||||
<div className="h-editor relative flex w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full w-8 flex justify-center p-1",
|
||||
"border-r border-mist-400 dark:border-mist-800",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"h-5 w-5 animate-spin rounded-full",
|
||||
"border-mist-900 dark:border-mist-100",
|
||||
"border-2 border-t-transparent dark:border-t-transparent",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn("h-editor overflow-hidden rounded-md", "bg-[var(--cm-bg)] text-[var(--cm-fg)]")}
|
||||
>
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-2 text-[var(--cm-gutter-fg)]">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
<p className="text-sm">Loading editor…</p>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
className={cn(
|
||||
"w-full h-editor font-mono resize-none text-sm",
|
||||
"bg-mist-50 dark:bg-mist-950 opacity-60",
|
||||
"pl-1 pt-1 leading-snug",
|
||||
)}
|
||||
readOnly
|
||||
value={acl}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||
import { EditorView } from "@codemirror/view";
|
||||
import { tags as t } from "@lezer/highlight";
|
||||
|
||||
const editorTheme = EditorView.theme({
|
||||
"&": {
|
||||
backgroundColor: "var(--cm-bg)",
|
||||
color: "var(--cm-fg)",
|
||||
},
|
||||
"&.cm-editor.cm-focused": {
|
||||
outline: "none",
|
||||
},
|
||||
".cm-content": {
|
||||
caretColor: "var(--cm-caret)",
|
||||
fontFamily: "var(--font-mono, ui-monospace, monospace)",
|
||||
},
|
||||
"&.cm-editor .cm-scroller": {
|
||||
fontFamily: "var(--font-mono, ui-monospace, monospace)",
|
||||
},
|
||||
".cm-cursor, .cm-dropCursor": {
|
||||
borderLeftColor: "var(--cm-caret)",
|
||||
},
|
||||
"&.cm-focused .cm-selectionBackground, & .cm-line::selection, & .cm-selectionLayer .cm-selectionBackground, .cm-content ::selection":
|
||||
{
|
||||
background: "var(--cm-selection) !important",
|
||||
},
|
||||
"& .cm-selectionMatch": {
|
||||
backgroundColor: "var(--cm-selection-match)",
|
||||
},
|
||||
".cm-activeLine": {
|
||||
backgroundColor: "var(--cm-line-highlight)",
|
||||
},
|
||||
".cm-gutters": {
|
||||
backgroundColor: "var(--cm-gutter-bg)",
|
||||
color: "var(--cm-gutter-fg)",
|
||||
borderRight: "1px solid var(--cm-gutter-border)",
|
||||
},
|
||||
".cm-activeLineGutter": {
|
||||
backgroundColor: "var(--cm-line-highlight)",
|
||||
color: "var(--cm-gutter-fg-active)",
|
||||
},
|
||||
".cm-scroller": {
|
||||
scrollbarColor: "var(--cm-gutter-border) transparent",
|
||||
scrollbarWidth: "auto",
|
||||
},
|
||||
});
|
||||
|
||||
const highlightStyle = HighlightStyle.define([
|
||||
{ tag: [t.comment, t.quote], color: "var(--cm-comment)" },
|
||||
{ tag: [t.keyword], color: "var(--cm-keyword)", fontWeight: "bold" },
|
||||
{ tag: [t.string, t.meta], color: "var(--cm-string)" },
|
||||
{ tag: [t.typeName, t.typeOperator], color: "var(--cm-type)" },
|
||||
{ tag: [t.definition(t.variableName)], color: "var(--cm-definition)" },
|
||||
{ tag: [t.name], color: "var(--cm-name)" },
|
||||
{ tag: [t.variableName], color: "var(--cm-variable)" },
|
||||
{ tag: [t.propertyName], color: "var(--cm-property)" },
|
||||
{ tag: [t.atom, t.bool, t.special(t.variableName)], color: "var(--cm-atom)" },
|
||||
{ tag: [t.number], color: "var(--cm-number)" },
|
||||
{ tag: [t.regexp, t.link], color: "var(--cm-link)" },
|
||||
{ tag: [t.bracket], color: "var(--cm-bracket)" },
|
||||
]);
|
||||
|
||||
export const headplaneTheme = [editorTheme, syntaxHighlighting(highlightStyle)];
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AlertCircle, Construction, Eye, FlaskConical, Pencil } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Suspense, lazy, useEffect, useState } from "react";
|
||||
import { isRouteErrorResponse, useFetcher, useRevalidator } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
@@ -15,7 +15,14 @@ import toast from "~/utils/toast";
|
||||
import type { Route } from "./+types/overview";
|
||||
import { aclAction } from "./acl-action";
|
||||
import { aclLoader } from "./acl-loader";
|
||||
import { Differ, Editor } from "./components/cm.client";
|
||||
import Fallback from "./components/fallback";
|
||||
|
||||
const LazyEditor = lazy(() =>
|
||||
import("./components/cm.client").then((m) => ({ default: m.Editor })),
|
||||
);
|
||||
const LazyDiffer = lazy(() =>
|
||||
import("./components/cm.client").then((m) => ({ default: m.Differ })),
|
||||
);
|
||||
|
||||
export const loader = aclLoader;
|
||||
export const action = aclAction;
|
||||
@@ -101,10 +108,14 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
|
||||
</TabsTab>
|
||||
</TabsList>
|
||||
<TabsPanel value="edit">
|
||||
<Editor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<LazyEditor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
|
||||
</Suspense>
|
||||
</TabsPanel>
|
||||
<TabsPanel value="diff">
|
||||
<Differ left={policy} right={codePolicy} />
|
||||
<Suspense fallback={<Fallback />}>
|
||||
<LazyDiffer left={policy} right={codePolicy} />
|
||||
</Suspense>
|
||||
</TabsPanel>
|
||||
<TabsPanel value="preview">
|
||||
<div className="flex flex-col items-center py-8">
|
||||
|
||||
@@ -33,9 +33,11 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
|
||||
};
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
// Build a client with the candidate API key the user just submitted, so the
|
||||
// GET /api/v1/apikey call below validates the key against Headscale itself.
|
||||
const api = context.headscale.client(apiKey);
|
||||
try {
|
||||
const apiKeys = await api.getApiKeys();
|
||||
const apiKeys = await api.apiKeys.list();
|
||||
|
||||
// We don't need to check for 0 API keys because this request cannot
|
||||
// be authenticated correctly without an API key
|
||||
|
||||
@@ -3,7 +3,7 @@ import { AlertCircle, CloudOff } from "lucide-react";
|
||||
import Card from "~/components/card";
|
||||
import Code from "~/components/code";
|
||||
import Link from "~/components/link";
|
||||
import type { OidcConnectorError } from "~/server/web/oidc-connector";
|
||||
import type { OidcErrorCode } from "~/server/oidc/provider";
|
||||
|
||||
export function OidcDiscoveryFailedNotice() {
|
||||
return (
|
||||
@@ -20,7 +20,7 @@ export function OidcDiscoveryFailedNotice() {
|
||||
);
|
||||
}
|
||||
|
||||
export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[] }) {
|
||||
export function OidcConfigErrorNotice({ errors }: { errors: OidcErrorCode[] }) {
|
||||
return (
|
||||
<Card className="m-4 mb-4 max-w-md border border-red-500 sm:m-0 sm:mb-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
@@ -34,7 +34,7 @@ export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[]
|
||||
<li key={code.key}>{code.node}</li>
|
||||
))}
|
||||
</ul>{" "}
|
||||
<Link external styled to="https://headplane.net/configuration/sso#troubleshooting">
|
||||
<Link external styled to="https://headplane.net/features/sso#troubleshooting">
|
||||
Learn more
|
||||
</Link>
|
||||
</Card.Text>
|
||||
@@ -42,7 +42,7 @@ export function OidcConfigErrorNotice({ errors }: { errors: OidcConnectorError[]
|
||||
);
|
||||
}
|
||||
|
||||
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
|
||||
function mapOidcErrorsToMessages(errors: OidcErrorCode[]) {
|
||||
const messages: {
|
||||
key: string;
|
||||
node: React.ReactNode;
|
||||
@@ -50,7 +50,7 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
|
||||
|
||||
for (const error of errors) {
|
||||
switch (error) {
|
||||
case "INVALID_API_KEY": {
|
||||
case "invalid_api_key": {
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
@@ -63,65 +63,39 @@ function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
|
||||
break;
|
||||
}
|
||||
|
||||
case "MISSING_AUTHORIZATION_ENDPOINT": {
|
||||
case "missing_endpoints": {
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
The OIDC provided does not have a configured <Code>authorization_endpoint</Code>.
|
||||
Ensure discovery URL or manual configuration is correct.
|
||||
The OIDC provider is missing required endpoints. Ensure the discovery URL is correct
|
||||
or provide manual endpoint overrides in your configuration.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "MISSING_TOKEN_ENDPOINT": {
|
||||
case "discovery_failed": {
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
The OIDC provided does not have a configured <Code>token_endpoint</Code>. Ensure
|
||||
discovery URL or manual configuration is correct.
|
||||
Unable to reach the OIDC provider for discovery. SSO will retry on the next login
|
||||
attempt.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "MISSING_USERINFO_ENDPOINT": {
|
||||
default: {
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
The OIDC provided does not have a configured <Code>user_endpoint</Code>. Ensure
|
||||
discovery URL or manual configuration is correct.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "MISSING_REQUIRED_CLAIMS": {
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
The OIDC provider does not support the <Code>sub</Code> claim, which is required for
|
||||
authentication. Your OIDC provider may be misconfigured.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case "UNKNOWN_ERROR": {
|
||||
messages.push({
|
||||
key: error,
|
||||
node: (
|
||||
<Card.Text className="inline">
|
||||
An unknown error occurred during OIDC configuration. Please check the Headplane logs
|
||||
for more information.
|
||||
An unknown OIDC configuration error occurred. Please check the Headplane logs for more
|
||||
information.
|
||||
</Card.Text>
|
||||
),
|
||||
});
|
||||
|
||||
@@ -24,16 +24,25 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const qp = new URL(request.url).searchParams;
|
||||
const urlState = qp.get("s") ?? undefined;
|
||||
|
||||
const oidcConnector = await context.oidc?.connector.get();
|
||||
const oidcService = context.oidc.state === "enabled" ? context.oidc.value : undefined;
|
||||
const oidcStatus = oidcService
|
||||
? await oidcService.discover().then(
|
||||
(r) => (r.ok ? oidcService.status() : oidcService.status()),
|
||||
() => oidcService.status(),
|
||||
)
|
||||
: undefined;
|
||||
|
||||
// MARK: This works because the OIDC connector will always return false
|
||||
// For `isExclusive` if the OIDC config isn't usable.
|
||||
if (oidcConnector?.isExclusive && urlState !== "logout") {
|
||||
if (
|
||||
oidcService &&
|
||||
context.config.oidc?.disable_api_key_login &&
|
||||
oidcStatus?.state === "ready" &&
|
||||
urlState !== "logout"
|
||||
) {
|
||||
return redirect("/oidc/start");
|
||||
}
|
||||
|
||||
const isOidcConnectorEnabled = oidcConnector?.isValid;
|
||||
const oidcErrorCodes = !isOidcConnectorEnabled ? (oidcConnector?.errors ?? []) : [];
|
||||
const isOidcConnectorEnabled = oidcStatus?.state === "ready";
|
||||
const oidcErrorCodes = oidcStatus?.state === "error" ? [oidcStatus.error.code] : [];
|
||||
|
||||
return {
|
||||
isCookieSecureEnabled: context.config.server.cookie_secure,
|
||||
@@ -88,7 +97,7 @@ export default function Page({ loaderData, actionData }: Route.ComponentProps) {
|
||||
<div>
|
||||
{urlState?.startsWith("error_") ? (
|
||||
<OidcErrorNotice code={urlState} />
|
||||
) : oidcErrorCodes.includes("DISCOVERY_FAILED") ? (
|
||||
) : oidcErrorCodes.includes("discovery_failed") ? (
|
||||
<OidcDiscoveryFailedNotice />
|
||||
) : oidcErrorCodes.length > 0 ? (
|
||||
<OidcConfigErrorNotice errors={oidcErrorCodes} />
|
||||
|
||||
@@ -1,21 +1,46 @@
|
||||
import { type ActionFunctionArgs, redirect } from "react-router";
|
||||
|
||||
import type { LoadContext } from "~/server";
|
||||
import type { AppContext } from "~/server/context";
|
||||
|
||||
export async function loader() {
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
export async function action({ request, context }: ActionFunctionArgs<LoadContext>) {
|
||||
export async function action({ request, context }: ActionFunctionArgs<AppContext>) {
|
||||
let principal: Awaited<ReturnType<typeof context.auth.require>> | undefined;
|
||||
try {
|
||||
await context.auth.require(request);
|
||||
principal = await context.auth.require(request);
|
||||
} catch {
|
||||
redirect("/login");
|
||||
return redirect("/login");
|
||||
}
|
||||
|
||||
// When API key is disabled, we need to explicitly redirect
|
||||
// with a logout state to prevent auto login again.
|
||||
const url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
|
||||
let url = context.config.oidc?.disable_api_key_login ? "/login?s=logout" : "/login";
|
||||
|
||||
// For OIDC sessions, redirect to the provider's RP-initiated logout
|
||||
// endpoint when explicitly enabled, so the upstream IdP session is also
|
||||
// ended. Disabled by default because the post_logout_redirect_uri must be
|
||||
// pre-registered on the IdP — turning this on without registering it would
|
||||
// strand users on the IdP's error page.
|
||||
if (
|
||||
principal?.kind === "oidc" &&
|
||||
context.oidc.state === "enabled" &&
|
||||
context.config.oidc?.use_end_session
|
||||
) {
|
||||
const service = context.oidc.value;
|
||||
const status = service.status();
|
||||
if (status.state !== "ready") {
|
||||
// Trigger discovery if it hasn't happened yet so we can find the
|
||||
// end_session_endpoint without forcing a re-login.
|
||||
await service.discover();
|
||||
}
|
||||
|
||||
const endSessionUrl = service.buildEndSessionUrl(principal.idToken);
|
||||
if (endSessionUrl) {
|
||||
url = endSessionUrl;
|
||||
}
|
||||
}
|
||||
|
||||
return redirect(url, {
|
||||
headers: {
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import * as oidc from "openid-client";
|
||||
import { data, redirect } from "react-router";
|
||||
|
||||
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
||||
@@ -10,10 +7,10 @@ import { createOidcStateCookie } from "~/utils/oidc-state";
|
||||
import type { Route } from "./+types/oidc-callback";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const oidcConnector = await context.oidc?.connector.get();
|
||||
if (!oidcConnector?.isValid) {
|
||||
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
||||
if (context.oidc.state !== "enabled") {
|
||||
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
|
||||
}
|
||||
const service = context.oidc.value;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.toString().length === 0) {
|
||||
@@ -34,138 +31,59 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
return redirect("/login?s=error_invalid_session");
|
||||
}
|
||||
|
||||
try {
|
||||
const callbackUrl = new URL(redirect_uri);
|
||||
const currentUrl = new URL(request.url);
|
||||
callbackUrl.search = currentUrl.search;
|
||||
const flowState = {
|
||||
state,
|
||||
nonce,
|
||||
codeVerifier: verifier,
|
||||
redirectUri: redirect_uri,
|
||||
};
|
||||
|
||||
const tokens = await oidc.authorizationCodeGrant(oidcConnector.client, callbackUrl, {
|
||||
expectedState: state,
|
||||
expectedNonce: nonce,
|
||||
...(oidcConnector.usePKCE ? { pkceCodeVerifier: verifier } : {}),
|
||||
});
|
||||
|
||||
const claims = tokens.claims();
|
||||
if (claims?.sub == null) {
|
||||
log.warn("auth", "No subject found in OIDC claims");
|
||||
return redirect("/login?s=error_no_sub");
|
||||
}
|
||||
|
||||
const userInfo = await oidc.fetchUserInfo(
|
||||
oidcConnector.client,
|
||||
tokens.access_token,
|
||||
claims.sub,
|
||||
);
|
||||
|
||||
// We have defaults that closely follow what Headscale uses, maybe we
|
||||
// can make it configurable in the future, but for now we only need the
|
||||
// `sub` claim.
|
||||
const username = userInfo.preferred_username ?? userInfo.email?.split("@")[0] ?? "user";
|
||||
const name =
|
||||
userInfo.name ??
|
||||
(userInfo.given_name && userInfo.family_name
|
||||
? `${userInfo.given_name} ${userInfo.family_name}`
|
||||
: (userInfo.preferred_username ?? "SSO User"));
|
||||
|
||||
const picture = await (async () => {
|
||||
if (context.config.oidc?.profile_picture_source === "gravatar") {
|
||||
if (!userInfo.email) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const emailHash = userInfo.email.trim().toLowerCase();
|
||||
const hash = createHash("sha256").update(emailHash).digest("hex");
|
||||
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
|
||||
}
|
||||
|
||||
if (!userInfo.picture) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(userInfo.picture, {
|
||||
headers: { Authorization: `Bearer ${tokens.access_token}` },
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (contentType?.startsWith("image/")) {
|
||||
const buffer = await response.arrayBuffer();
|
||||
const base64 = Buffer.from(buffer).toString("base64");
|
||||
return `data:${contentType};base64,${base64}`;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
|
||||
return userInfo.picture;
|
||||
})();
|
||||
|
||||
const userId = await context.auth.findOrCreateUser(claims.sub, {
|
||||
name,
|
||||
email: userInfo.email,
|
||||
picture,
|
||||
});
|
||||
|
||||
try {
|
||||
const hsApi = context.hsApi.getRuntimeClient(context.oidc!.apiKey);
|
||||
const hsUsers = await hsApi.getUsers();
|
||||
const hsUser = findHeadscaleUserBySubject(hsUsers, claims.sub, userInfo.email);
|
||||
if (hsUser) {
|
||||
await context.auth.linkHeadscaleUser(userId, hsUser.id);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("auth", "Failed to link Headscale user: %s", String(error));
|
||||
}
|
||||
|
||||
return redirect("/", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.createOidcSession(userId, {
|
||||
name,
|
||||
email: userInfo.email,
|
||||
username,
|
||||
}),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof oidc.ResponseBodyError) {
|
||||
log.error("auth", "Got an OIDC response error body: %s", JSON.stringify(error.cause));
|
||||
|
||||
// Check for PKCE-related errors
|
||||
if (
|
||||
error.error.toLowerCase().includes("code_verifier") ||
|
||||
error.error.toLowerCase().includes("code verifier") ||
|
||||
error.error.toLowerCase().includes("pkce")
|
||||
) {
|
||||
log.error(
|
||||
"auth",
|
||||
"PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s",
|
||||
oidcConnector.usePKCE,
|
||||
);
|
||||
|
||||
if (!oidcConnector.usePKCE) {
|
||||
log.error(
|
||||
"auth",
|
||||
"Consider setting oidc.use_pkce=true in your configuration if your provider requires PKCE",
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (error instanceof oidc.AuthorizationResponseError) {
|
||||
log.error("auth", "Got an OIDC authorization response error: %s", error.error);
|
||||
} else if (error instanceof oidc.WWWAuthenticateChallengeError) {
|
||||
log.error("auth", "Got an OIDC WWW-Authenticate challenge error");
|
||||
} else if (error instanceof oidc.ClientError) {
|
||||
log.error(
|
||||
"auth",
|
||||
"Got an OIDC authorization client error: %s",
|
||||
error.cause instanceof Error ? error.cause.message : String(error.cause),
|
||||
);
|
||||
} else {
|
||||
log.error(
|
||||
"auth",
|
||||
"Got an OIDC error: %s",
|
||||
error instanceof Error && error.cause ? JSON.stringify(error.cause) : String(error),
|
||||
);
|
||||
const result = await service.handleCallback(url.searchParams, flowState);
|
||||
if (!result.ok) {
|
||||
log.error("auth", "OIDC callback failed [%s]: %s", result.error.code, result.error.message);
|
||||
if (result.error.hint) {
|
||||
log.error("auth", "Hint: %s", result.error.hint);
|
||||
}
|
||||
return redirect("/login?s=error_auth_failed");
|
||||
}
|
||||
|
||||
const identity = result.value;
|
||||
|
||||
const userId = await context.auth.findOrCreateUser(identity.subject, {
|
||||
name: identity.name,
|
||||
email: identity.email,
|
||||
picture: identity.picture,
|
||||
});
|
||||
|
||||
try {
|
||||
// Looks up the Headscale user that matches this OIDC identity. We use
|
||||
// the configured admin API key here — not a per-request one — because
|
||||
// there is no per-request key yet (the session is being created).
|
||||
const hsApi = context.headscale.client(context.headscaleApiKey!);
|
||||
const hsUsers = await hsApi.users.list();
|
||||
const hsUser = findHeadscaleUserBySubject(hsUsers, identity.subject, identity.email);
|
||||
if (hsUser) {
|
||||
await context.auth.linkHeadscaleUser(userId, hsUser.id);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn("auth", "Failed to link Headscale user: %s", String(error));
|
||||
}
|
||||
|
||||
// Only persist the id_token when RP-initiated logout is enabled — otherwise
|
||||
// we'd be storing a credential we never use.
|
||||
const idToken = context.config.oidc?.use_end_session ? identity.idToken : undefined;
|
||||
|
||||
return redirect("/", {
|
||||
headers: {
|
||||
"Set-Cookie": await context.auth.createOidcSession(
|
||||
userId,
|
||||
{
|
||||
name: identity.name,
|
||||
email: identity.email,
|
||||
username: identity.username,
|
||||
},
|
||||
{ idToken },
|
||||
),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import * as oidc from "openid-client";
|
||||
import { data, redirect } from "react-router";
|
||||
|
||||
import { HeadplaneConfig } from "~/server/config/config-schema";
|
||||
import { createOidcStateCookie } from "~/utils/oidc-state";
|
||||
|
||||
import type { Route } from "./+types/oidc-start";
|
||||
@@ -12,70 +10,28 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
return redirect("/");
|
||||
} catch {}
|
||||
|
||||
const oidcConnector = await context.oidc?.connector.get();
|
||||
if (!oidcConnector?.isValid) {
|
||||
throw data("OIDC is not enabled or misconfigured", { status: 501 });
|
||||
if (context.oidc.state !== "enabled") {
|
||||
throw data(`OIDC is unavailable: ${context.oidc.reason}`, { status: 501 });
|
||||
}
|
||||
const service = context.oidc.value;
|
||||
|
||||
const result = await service.startFlow();
|
||||
if (!result.ok) {
|
||||
return redirect(`/login?s=${result.error.code}`);
|
||||
}
|
||||
|
||||
const { url, flowState } = result.value;
|
||||
const cookie = createOidcStateCookie(context.config);
|
||||
const redirect_uri = getRedirectUri(context.config, request);
|
||||
|
||||
const nonce = oidc.randomNonce();
|
||||
const verifier = oidc.randomPKCECodeVerifier();
|
||||
const state = oidc.randomState();
|
||||
|
||||
const url = oidc.buildAuthorizationUrl(oidcConnector.client, {
|
||||
...oidcConnector.extraParams,
|
||||
scope: oidcConnector.scope,
|
||||
redirect_uri,
|
||||
state,
|
||||
nonce,
|
||||
...(oidcConnector.usePKCE
|
||||
? {
|
||||
code_challenge_method: "S256",
|
||||
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
|
||||
return redirect(url.href, {
|
||||
return redirect(url, {
|
||||
status: 302,
|
||||
headers: {
|
||||
"Set-Cookie": await cookie.serialize({
|
||||
state,
|
||||
nonce,
|
||||
verifier,
|
||||
redirect_uri,
|
||||
state: flowState.state,
|
||||
nonce: flowState.nonce,
|
||||
verifier: flowState.codeVerifier,
|
||||
redirect_uri: flowState.redirectUri,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getRedirectUri(config: HeadplaneConfig, req: Request): string {
|
||||
if (config.server.base_url != null) {
|
||||
const url = new URL(`${__PREFIX__}/oidc/callback`, config.server.base_url);
|
||||
return url.href;
|
||||
}
|
||||
|
||||
if (config.oidc?.redirect_uri != null) {
|
||||
const url = new URL(`${__PREFIX__}/oidc/callback`, config.oidc.redirect_uri);
|
||||
return url.href;
|
||||
}
|
||||
|
||||
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
|
||||
let host = req.headers.get("Host");
|
||||
if (!host) {
|
||||
host = req.headers.get("X-Forwarded-Host");
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
throw data("Cannot determine redirect URI: no Host or X-Forwarded-Host header", {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
|
||||
const proto = req.headers.get("X-Forwarded-Proto");
|
||||
url.protocol = proto ?? "http:";
|
||||
url.host = host;
|
||||
return url.href;
|
||||
}
|
||||
|
||||
@@ -16,9 +16,6 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return data({ success: false }, 403);
|
||||
}
|
||||
|
||||
// We only need it for health checks which don't require auth
|
||||
const api = context.hsApi.getRuntimeClient("fake-api-key");
|
||||
|
||||
const formData = await request.formData();
|
||||
const action = formData.get("action_id")?.toString();
|
||||
if (!action) {
|
||||
@@ -39,7 +36,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "Tailnet renamed successfully" };
|
||||
}
|
||||
case "toggle_magic": {
|
||||
@@ -55,7 +52,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "Magic DNS state updated successfully" };
|
||||
}
|
||||
case "remove_ns": {
|
||||
@@ -88,7 +85,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
]);
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "Nameserver removed successfully" };
|
||||
}
|
||||
case "add_ns": {
|
||||
@@ -123,7 +120,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
]);
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "Nameserver added successfully" };
|
||||
}
|
||||
case "remove_domain": {
|
||||
@@ -141,7 +138,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "Domain removed successfully" };
|
||||
}
|
||||
case "add_domain": {
|
||||
@@ -161,7 +158,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "Domain added successfully" };
|
||||
}
|
||||
case "remove_record": {
|
||||
@@ -183,7 +180,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "DNS record removed successfully" };
|
||||
}
|
||||
case "add_record": {
|
||||
@@ -205,7 +202,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
return;
|
||||
}
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "DNS record added successfully" };
|
||||
}
|
||||
case "override_dns": {
|
||||
@@ -222,7 +219,7 @@ export async function dnsAction({ request, context }: Route.ActionArgs) {
|
||||
},
|
||||
]);
|
||||
|
||||
await context.integration?.onConfigChange(api);
|
||||
await context.integration?.onConfigChange(context.headscale);
|
||||
return { message: "DNS override updated successfully" };
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useLoaderData } from "react-router";
|
||||
import Code from "~/components/code";
|
||||
import Notice from "~/components/notice";
|
||||
import PageError from "~/components/page-error";
|
||||
import type { LoadContext } from "~/server";
|
||||
import type { AppContext } from "~/server/context";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
|
||||
import ManageDomains from "./components/manage-domains";
|
||||
@@ -15,7 +15,7 @@ import ToggleMagic from "./components/toggle-magic";
|
||||
import { dnsAction } from "./dns-actions";
|
||||
|
||||
// We do not want to expose every config value
|
||||
export async function loader({ request, context }: LoaderFunctionArgs<LoadContext>) {
|
||||
export async function loader({ request, context }: LoaderFunctionArgs<AppContext>) {
|
||||
if (!context.hs.readable()) {
|
||||
throw new Error("No configuration is available");
|
||||
}
|
||||
|
||||
+3
-5
@@ -24,12 +24,11 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
// Unclaimed users they can pick from before anything else.
|
||||
let unlinked = false;
|
||||
if (
|
||||
typeof context.oidc === "object" &&
|
||||
context.oidc.state === "enabled" &&
|
||||
principal.kind === "oidc" &&
|
||||
!principal.user.headscaleUserId
|
||||
) {
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
|
||||
let headscaleUsers: { id: string; name: string }[] = [];
|
||||
try {
|
||||
@@ -64,8 +63,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
}
|
||||
|
||||
// No UI access — show the download/connect page
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
|
||||
let linkedUserName: string | undefined;
|
||||
if (principal.kind === "oidc" && principal.user.headscaleUserId) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { TailscaleSSHTag } from "~/components/tags/TailscaleSSH";
|
||||
import type { User } from "~/types";
|
||||
import cn from "~/utils/cn";
|
||||
import * as hinfo from "~/utils/host-info";
|
||||
import type { PopulatedNode } from "~/utils/node-info";
|
||||
import { isNoExpiry, type PopulatedNode } from "~/utils/node-info";
|
||||
import { formatTimeDelta } from "~/utils/time";
|
||||
import toast from "~/utils/toast";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
@@ -50,8 +50,8 @@ export default function MachineRow({
|
||||
}, [magic, node.ipAddresses]);
|
||||
|
||||
return (
|
||||
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={node.id}>
|
||||
<td className="py-2 pl-0.5 focus-within:ring-3">
|
||||
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={node.id}>
|
||||
<td className="py-2 pl-2 focus-within:ring-3">
|
||||
<Link className={cn("group/link h-full focus:outline-hidden")} to={`/machines/${node.id}`}>
|
||||
<p
|
||||
className={cn(
|
||||
@@ -156,7 +156,7 @@ export function uiTagsForNode(node: PopulatedNode, isAgent?: boolean) {
|
||||
uiTags.push("expired");
|
||||
}
|
||||
|
||||
if (node.expiry === null) {
|
||||
if (!node.expired && isNoExpiry(node.expiry)) {
|
||||
uiTags.push("no-expiry");
|
||||
}
|
||||
|
||||
|
||||
@@ -107,7 +107,7 @@ export default function MachineMenu({
|
||||
// in a new WINDOW since href can only
|
||||
// do a new TAB.
|
||||
window.open(
|
||||
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
||||
`${__PREFIX__}/ssh/${node.givenName}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer,width=800,height=600",
|
||||
);
|
||||
@@ -120,17 +120,14 @@ export default function MachineMenu({
|
||||
) : (
|
||||
<Button
|
||||
className={cn(
|
||||
"py-0.5",
|
||||
"py-0.5 rounded-lg",
|
||||
"opacity-0 pointer-events-none group-hover:opacity-100",
|
||||
"group-hover:pointer-events-auto",
|
||||
)}
|
||||
variant="ghost"
|
||||
variant="light"
|
||||
onClick={() => {
|
||||
// We need to use JS to open the SSH URL
|
||||
// in a new WINDOW since href can only
|
||||
// do a new TAB.
|
||||
window.open(
|
||||
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
|
||||
`${__PREFIX__}/ssh/${node.givenName}`,
|
||||
"_blank",
|
||||
"noopener,noreferrer,width=800,height=600",
|
||||
);
|
||||
|
||||
@@ -52,7 +52,9 @@ export default function NewMachine(data: NewMachineProps) {
|
||||
onValueChange={(v) => form.setValue("user", v)}
|
||||
placeholder="Select a user"
|
||||
items={data.users.map((user) => ({
|
||||
value: user.id,
|
||||
// Headscale's v1/node/register endpoint resolves the owner by
|
||||
// username via GetUserByName, so we must pass user.name (not id).
|
||||
value: user.name,
|
||||
label: getUserDisplayName(user),
|
||||
}))}
|
||||
/>
|
||||
|
||||
@@ -7,12 +7,9 @@ import { Capabilities } from "~/server/web/roles";
|
||||
import type { Route } from "./+types/machine";
|
||||
|
||||
export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const { principal, api } = await context.apiForRequest(request);
|
||||
|
||||
const formData = await request.formData();
|
||||
const api = context.hsApi.getRuntimeClient(
|
||||
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
|
||||
);
|
||||
|
||||
const action = formData.get("action_id")?.toString();
|
||||
if (!action) {
|
||||
@@ -43,7 +40,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const node = await api.registerNode(user, registrationKey);
|
||||
const node = await api.nodes.register(user, registrationKey);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return redirect(`/machines/${node.id}`);
|
||||
}
|
||||
@@ -56,7 +53,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const node = await api.getNode(nodeId);
|
||||
const node = await api.nodes.get(nodeId);
|
||||
if (!node) {
|
||||
throw data(`Machine with ID ${nodeId} not found`, {
|
||||
status: 404,
|
||||
@@ -79,19 +76,19 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
const name = String(formData.get("name"));
|
||||
await api.renameNode(nodeId, name);
|
||||
await api.nodes.rename(nodeId, name);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return { message: "Machine renamed" };
|
||||
}
|
||||
|
||||
case "delete": {
|
||||
await api.deleteNode(nodeId);
|
||||
await api.nodes.delete(nodeId);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
case "expire": {
|
||||
await api.expireNode(nodeId);
|
||||
await api.nodes.expire(nodeId);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return { message: "Machine expired" };
|
||||
}
|
||||
@@ -105,7 +102,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
try {
|
||||
await api.setNodeTags(
|
||||
await api.nodes.setTags(
|
||||
nodeId,
|
||||
tags.map((tag) => tag.trim()).filter((tag) => tag !== ""),
|
||||
);
|
||||
@@ -174,7 +171,7 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
await api.approveNodeRoutes(nodeId, newApproved);
|
||||
await api.nodes.approveRoutes(nodeId, newApproved);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return { message: "Routes updated" };
|
||||
}
|
||||
@@ -187,7 +184,12 @@ export async function machineAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
await api.setNodeUser(nodeId, user);
|
||||
if (!api.nodes.reassignUser) {
|
||||
throw data("Reassigning a node owner is no longer supported on this Headscale version.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
await api.nodes.reassignUser(nodeId, user);
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
return { message: "Machine reassigned" };
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import Tooltip from "~/components/tooltip";
|
||||
import { nodesResource, usersResource } from "~/server/headscale/live-store";
|
||||
import cn from "~/utils/cn";
|
||||
import { getOSInfo, getTSVersion } from "~/utils/host-info";
|
||||
import { mapNodes, sortNodeTags } from "~/utils/node-info";
|
||||
import { isNoExpiry, mapNodes, sortNodeTags } from "~/utils/node-info";
|
||||
import { getUserDisplayName } from "~/utils/user";
|
||||
|
||||
import type { Route } from "./+types/machine";
|
||||
@@ -22,7 +22,6 @@ import Routes from "./dialogs/routes";
|
||||
import { machineAction } from "./machine-actions";
|
||||
|
||||
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
if (!params.id) {
|
||||
throw new Error("No machine ID provided");
|
||||
}
|
||||
@@ -38,9 +37,7 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(
|
||||
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
|
||||
);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
const [nodesSnap, usersSnap] = await Promise.all([
|
||||
context.hsLive.get(nodesResource, api),
|
||||
context.hsLive.get(usersResource, api),
|
||||
@@ -52,18 +49,19 @@ export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
throw data(null, { status: 404 });
|
||||
}
|
||||
|
||||
const lookup = await context.agents?.lookup([node.nodeKey]);
|
||||
const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
|
||||
const lookup = await agents?.lookup([node.nodeKey]);
|
||||
const [enhancedNode] = mapNodes([node], lookup);
|
||||
const tags = [...node.tags].toSorted();
|
||||
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
|
||||
const agentSync = context.agents?.lastSync();
|
||||
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
|
||||
const agentSync = agents?.lastSync();
|
||||
|
||||
return {
|
||||
agent: agentSync
|
||||
? {
|
||||
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
|
||||
nodeCount: agentSync.nodeCount,
|
||||
nodeKey: context.agents?.agentNodeKey(),
|
||||
nodeKey: agents?.agentNodeKey(),
|
||||
}
|
||||
: undefined,
|
||||
existingTags: sortNodeTags(nodes),
|
||||
@@ -283,14 +281,16 @@ export default function Page({
|
||||
/>
|
||||
<Attribute
|
||||
name="Key expiry"
|
||||
value={node.expiry !== null ? new Date(node.expiry).toLocaleString() : "Never"}
|
||||
value={!isNoExpiry(node.expiry) ? new Date(node.expiry!).toLocaleString() : "Never"}
|
||||
/>
|
||||
{magic ? (
|
||||
<Attribute isCopyable name="Domain" value={`${node.givenName}.${magic}`} />
|
||||
) : undefined}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-semibold uppercase opacity-75">Addresses</p>
|
||||
<p className="text-sm font-semibold text-mist-600 uppercase dark:text-mist-300">
|
||||
Addresses
|
||||
</p>
|
||||
<Attribute
|
||||
isCopyable
|
||||
name="Tailscale IPv4"
|
||||
@@ -317,9 +317,14 @@ export default function Page({
|
||||
value={`${node.givenName}.${magic}`}
|
||||
/>
|
||||
) : undefined}
|
||||
{stats?.Endpoints ? (
|
||||
<Attribute name="Endpoints" value={stats?.Endpoints?.join("\n") ?? "—"} />
|
||||
) : undefined}
|
||||
{stats ? (
|
||||
<>
|
||||
<p className="mt-4 text-sm font-semibold uppercase opacity-75">Client Connectivity</p>
|
||||
<p className="mt-4 text-sm font-semibold text-mist-600 uppercase dark:text-mist-300">
|
||||
Client Connectivity
|
||||
</p>
|
||||
<Attribute
|
||||
name="Varies"
|
||||
tooltip="Whether the machine is behind a difficult NAT that varies the machine’s IP address depending on the destination."
|
||||
|
||||
@@ -30,9 +30,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
const writablePermission = context.auth.can(principal, Capabilities.write_machines);
|
||||
|
||||
const api = context.hsApi.getRuntimeClient(
|
||||
context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey),
|
||||
);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
const [nodesSnap, usersSnap] = await Promise.all([
|
||||
context.hsLive.get(nodesResource, api),
|
||||
context.hsLive.get(usersResource, api),
|
||||
@@ -47,17 +45,18 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
const stats = await context.agents?.lookup(nodes.map((node) => node.nodeKey));
|
||||
const agents = context.agents.state === "enabled" ? context.agents.value : undefined;
|
||||
const stats = await agents?.lookup(nodes.map((node) => node.nodeKey));
|
||||
const populatedNodes = mapNodes(nodes, stats);
|
||||
const supportsNodeOwnerChange = !context.hsApi.clientHelpers.isAtleast("0.28.0");
|
||||
const agentSync = context.agents?.lastSync();
|
||||
const supportsNodeOwnerChange = !context.headscale.capabilities.nodeOwnerIsImmutable;
|
||||
const agentSync = agents?.lastSync();
|
||||
|
||||
return {
|
||||
agent: agentSync
|
||||
? {
|
||||
syncedAt: agentSync.syncedAt?.toISOString() ?? null,
|
||||
nodeCount: agentSync.nodeCount,
|
||||
nodeKey: context.agents?.agentNodeKey(),
|
||||
nodeKey: agents?.agentNodeKey(),
|
||||
}
|
||||
: undefined,
|
||||
headscaleUserId: principal.kind === "oidc" ? principal.user.headscaleUserId : undefined,
|
||||
|
||||
@@ -11,13 +11,13 @@ import { formatTimeDelta } from "~/utils/time";
|
||||
import type { Route } from "./+types/agent";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
await context.auth.require(request);
|
||||
|
||||
if (!context.agents) {
|
||||
return { enabled: false as const };
|
||||
if (context.agents.state !== "enabled") {
|
||||
return { enabled: false as const, reason: context.agents.reason };
|
||||
}
|
||||
|
||||
const sync = context.agents.lastSync();
|
||||
const sync = context.agents.value.lastSync();
|
||||
return {
|
||||
enabled: true as const,
|
||||
syncedAt: sync.syncedAt?.toISOString() ?? null,
|
||||
@@ -29,12 +29,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
export async function action({ request, context }: Route.ActionArgs) {
|
||||
await context.auth.require(request);
|
||||
|
||||
if (!context.agents) {
|
||||
return { success: false, error: "Agent is not enabled" };
|
||||
if (context.agents.state !== "enabled") {
|
||||
return { success: false, error: context.agents.reason };
|
||||
}
|
||||
|
||||
await context.agents.triggerSync();
|
||||
const sync = context.agents.lastSync();
|
||||
await context.agents.value.triggerSync();
|
||||
const sync = context.agents.value.lastSync();
|
||||
return { success: !sync.error, error: sync.error };
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
<div className="flex max-w-(--breakpoint-lg) flex-col gap-8">
|
||||
<Title>Headplane Agent</Title>
|
||||
<Notice title="Agent Not Enabled">
|
||||
The Headplane Agent is not enabled. To learn how to set up the agent, visit the{" "}
|
||||
{loaderData.reason}. To learn how to set up the agent, visit the{" "}
|
||||
<Link external styled to="https://headplane.dev/docs/agent">
|
||||
documentation
|
||||
</Link>
|
||||
|
||||
@@ -2,13 +2,12 @@ import { data } from "react-router";
|
||||
|
||||
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import type { PreAuthKey } from "~/types";
|
||||
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { principal, api } = await context.apiForRequest(request);
|
||||
|
||||
const canGenerateAny = context.auth.can(principal, Capabilities.generate_authkeys);
|
||||
const canGenerateOwn = context.auth.can(principal, Capabilities.generate_own_authkeys);
|
||||
@@ -21,7 +20,7 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
|
||||
async function checkSelfServiceOwnership(userId: string) {
|
||||
if (canGenerateAny || !canGenerateOwn) return;
|
||||
const [targetUser] = await api.getUsers(userId);
|
||||
const [targetUser] = await api.users.list({ id: userId });
|
||||
if (!targetUser) {
|
||||
throw data("User not found.", { status: 404 });
|
||||
}
|
||||
@@ -85,20 +84,22 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + day);
|
||||
|
||||
const key = await api.createPreAuthKey(
|
||||
const key = await api.preAuthKeys.create({
|
||||
user,
|
||||
ephemeral === "on",
|
||||
reusable === "on",
|
||||
date,
|
||||
aclTags.length > 0 ? aclTags : null,
|
||||
);
|
||||
ephemeral: ephemeral === "on",
|
||||
reusable: reusable === "on",
|
||||
expiration: date,
|
||||
aclTags: aclTags.length > 0 ? aclTags : null,
|
||||
});
|
||||
|
||||
return data({ success: true as const, key: key.key });
|
||||
}
|
||||
|
||||
case "expire_preauthkey": {
|
||||
const keyId = formData.get("key_id")?.toString();
|
||||
const key = formData.get("key")?.toString();
|
||||
if (!key) {
|
||||
return data("Missing `key` in the form data.", {
|
||||
if (!keyId || !key) {
|
||||
return data("Missing `key_id` or `key` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
@@ -111,10 +112,18 @@ export async function authKeysAction({ request, context }: Route.ActionArgs) {
|
||||
}
|
||||
|
||||
await checkSelfServiceOwnership(user);
|
||||
|
||||
await api.expirePreAuthKey(user, key);
|
||||
// `user` here is the Headscale numeric user id (form field is wired
|
||||
// from User.id). Pre-0.28 expire posts a uint64 `user` field, which
|
||||
// the API layer reads from `key.user?.id`. Headscale 0.28+ only
|
||||
// looks at `key.id` (the stable preauthkey id).
|
||||
await api.preAuthKeys.expire({
|
||||
id: keyId,
|
||||
key,
|
||||
user: { id: user },
|
||||
} as unknown as PreAuthKey);
|
||||
return data("Pre-auth key expired");
|
||||
}
|
||||
|
||||
default:
|
||||
return data("Invalid action", {
|
||||
status: 400,
|
||||
|
||||
@@ -29,7 +29,8 @@ function findCurrentUser(users: User[], subject: string | undefined): User | und
|
||||
if (u.provider !== "oidc" || !u.providerId) {
|
||||
return false;
|
||||
}
|
||||
return u.providerId.split("/").pop() === subject;
|
||||
const segment = u.providerId.split("/").pop();
|
||||
return segment ? decodeURIComponent(segment) === subject : false;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
|
||||
<Title>Expire auth key?</Title>
|
||||
<input name="action_id" type="hidden" value="expire_preauthkey" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<input name="key_id" type="hidden" value={authKey.id} />
|
||||
<input name="key" type="hidden" value={authKey.key} />
|
||||
<Text>
|
||||
Expiring this authentication key will immediately prevent it from being used to
|
||||
|
||||
@@ -19,9 +19,7 @@ import AuthKeyRow from "./auth-key-row";
|
||||
import AddAuthKey from "./dialogs/add-auth-key";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { principal, api } = await context.apiForRequest(request);
|
||||
|
||||
const usersSnap = await context.hsLive.get(usersResource, api);
|
||||
const users = usersSnap.data;
|
||||
@@ -31,10 +29,12 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
|
||||
// Try fetching all keys at once (Headscale 0.28+), fall back to per-user
|
||||
let allKeys: PreAuthKey[] | null = null;
|
||||
try {
|
||||
allKeys = await api.getAllPreAuthKeys();
|
||||
} catch {
|
||||
// Older versions don't support this endpoint
|
||||
if (api.preAuthKeys.listAll) {
|
||||
try {
|
||||
allKeys = await api.preAuthKeys.listAll();
|
||||
} catch {
|
||||
// Treat any failure as "no global list available" and fall through.
|
||||
}
|
||||
}
|
||||
|
||||
if (allKeys !== null) {
|
||||
@@ -67,7 +67,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
.filter((u) => u.id?.length > 0)
|
||||
.map(async (user) => {
|
||||
try {
|
||||
const preAuthKeys = await api.getPreAuthKeys(user.id);
|
||||
const preAuthKeys = await api.preAuthKeys.listForUser(user.id);
|
||||
return { preAuthKeys, success: true as const, user };
|
||||
} catch (error) {
|
||||
log.error("api", "GET /v1/preauthkey for %s: %o", user.name, error);
|
||||
|
||||
@@ -6,10 +6,10 @@ import PageError from "~/components/page-error";
|
||||
import type { Route } from "./+types/overview";
|
||||
|
||||
export async function loader({ context }: Route.LoaderArgs) {
|
||||
const oidcConnector = await context.oidc?.connector.get();
|
||||
return {
|
||||
config: context.hs.writable(),
|
||||
isOidcEnabled: oidcConnector?.isValid ?? false,
|
||||
isOidcEnabled:
|
||||
context.oidc.state === "enabled" && context.oidc.value.status().state === "ready",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -28,8 +28,6 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
});
|
||||
}
|
||||
|
||||
// We only need healthchecks which don't rely on an API key
|
||||
const api = context.hsApi.getRuntimeClient("fake-api-key");
|
||||
switch (action) {
|
||||
case "add_domain": {
|
||||
const domain = formData.get("domain")?.toString()?.trim();
|
||||
@@ -48,7 +46,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
context.integration?.onConfigChange(context.headscale);
|
||||
return data("Domain added successfully.");
|
||||
}
|
||||
|
||||
@@ -76,7 +74,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
value: domains,
|
||||
},
|
||||
]);
|
||||
context.integration?.onConfigChange(api);
|
||||
context.integration?.onConfigChange(context.headscale);
|
||||
return data("Domain removed successfully.");
|
||||
}
|
||||
|
||||
@@ -97,7 +95,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
context.integration?.onConfigChange(context.headscale);
|
||||
return data("Group added successfully.");
|
||||
}
|
||||
|
||||
@@ -126,7 +124,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
context.integration?.onConfigChange(context.headscale);
|
||||
return data("Group removed successfully.");
|
||||
}
|
||||
|
||||
@@ -147,7 +145,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
context.integration?.onConfigChange(context.headscale);
|
||||
return data("User added successfully.");
|
||||
}
|
||||
|
||||
@@ -176,7 +174,7 @@ export async function restrictionAction({ request, context }: Route.ActionArgs)
|
||||
},
|
||||
]);
|
||||
|
||||
context.integration?.onConfigChange(api);
|
||||
context.integration?.onConfigChange(context.headscale);
|
||||
return data("User removed successfully.");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
import { faker } from "@faker-js/faker";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { data, type ShouldRevalidateFunction, useSubmit } from "react-router";
|
||||
import { ExternalScriptsHandle } from "remix-utils/external-scripts";
|
||||
|
||||
import { EphemeralNodeInsert, ephemeralNodes } from "~/server/db/schema";
|
||||
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
||||
import { useLiveData } from "~/utils/live-data";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "./+types/console";
|
||||
import UserPrompt from "./user-prompt";
|
||||
import XTerm from "./xterm.client";
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const origin = new URL(request.url).origin;
|
||||
const assets = [`${__PREFIX__}/wasm_exec.js`, `${__PREFIX__}/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) {
|
||||
throw data("WebSSH is only available with the Headplane agent integration", 400);
|
||||
}
|
||||
|
||||
const principal = await context.auth.require(request);
|
||||
if (principal.kind === "api_key") {
|
||||
throw data("Only OAuth users are allowed to use WebSSH", 403);
|
||||
}
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const users = await api.getUsers();
|
||||
|
||||
// MARK: This assumes that a user has authenticated with Headscale first
|
||||
// Since the only way to enforce permissions via ACLs is to generate a
|
||||
// pre-authkey which REQUIRES a user ID, meaning the user has to have
|
||||
// authenticated with Headscale first.
|
||||
const lookup = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
|
||||
|
||||
if (!lookup) {
|
||||
throw data(`User with subject ${principal.user.subject} not found within Headscale`, 404);
|
||||
}
|
||||
|
||||
const preAuthKey = await api.createPreAuthKey(
|
||||
lookup.id,
|
||||
true, // ephemeral
|
||||
false, // reusable
|
||||
new Date(Date.now() + 60 * 1000), // expiration: 1 minute
|
||||
null, // aclTags
|
||||
);
|
||||
|
||||
// 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 api.getNodes();
|
||||
const lookupNode = nodes.find((n) => n.givenName === hostname);
|
||||
if (!lookupNode) {
|
||||
throw data(`Node with hostname ${hostname} not found`, 404);
|
||||
}
|
||||
|
||||
// Last thing is keeping track of the ephemeral node in the database
|
||||
// because Headscale doesn't automatically delete ephemeral nodes???
|
||||
const [_ephemeralNode] = await context.db
|
||||
.insert(ephemeralNodes)
|
||||
.values({
|
||||
auth_key: preAuthKey.key,
|
||||
} satisfies EphemeralNodeInsert)
|
||||
.returning();
|
||||
|
||||
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 }: Route.ActionArgs) {
|
||||
await context.auth.require(request);
|
||||
if (!context.agents) {
|
||||
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));
|
||||
|
||||
context.agents?.triggerSync().catch((err) => {
|
||||
log.debug("agent", "Background agent sync failed: %s", err);
|
||||
});
|
||||
}
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{
|
||||
rel: "preload",
|
||||
href: `${__PREFIX__}/hp_ssh.wasm`,
|
||||
as: "fetch",
|
||||
type: "application/wasm",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
];
|
||||
|
||||
export const handle: ExternalScriptsHandle = {
|
||||
scripts: [
|
||||
{
|
||||
src: `${__PREFIX__}/wasm_exec.js`,
|
||||
crossOrigin: "anonymous",
|
||||
preload: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export default function Page({ loaderData: { ipnDetails, sshDetails } }: Route.ComponentProps) {
|
||||
const submit = useSubmit();
|
||||
const { pause } = useLiveData();
|
||||
|
||||
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
|
||||
const [nodeKey, setNodeKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ipnDetails) {
|
||||
return;
|
||||
}
|
||||
|
||||
pause();
|
||||
const go = new Go(); // Go is defined by wasm_exec.js
|
||||
WebAssembly.instantiateStreaming(fetch(`${__PREFIX__}/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="h-screen w-screen bg-mist-900">
|
||||
{ipn === null ? (
|
||||
<div className="mx-auto flex h-screen items-center justify-center">
|
||||
<Loader2 className="size-10 animate-spin text-mist-50" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-screen flex-col">
|
||||
<XTerm hostname={sshDetails.hostname} ipn={ipn} username={sshDetails.username} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { AlertCircle } from "lucide-react";
|
||||
|
||||
import Card from "~/components/card";
|
||||
import Link from "~/components/link";
|
||||
|
||||
export const sshErrors = {
|
||||
wasm_missing: {
|
||||
title: "Browser SSH is not available",
|
||||
message: "This version of Headplane was not built with browser SSH support.",
|
||||
anchor: "#ssh-not-available",
|
||||
},
|
||||
|
||||
agent_required: {
|
||||
title: "Browser SSH requires the Headplane agent",
|
||||
message: "Browser SSH is only available when the Headplane agent integration is enabled.",
|
||||
anchor: "#agent-required",
|
||||
},
|
||||
|
||||
oidc_required: {
|
||||
title: "Browser SSH requires OIDC authentication",
|
||||
message: "Browser SSH is only available when OIDC authentication is enabled.",
|
||||
anchor: "#oidc-required",
|
||||
},
|
||||
|
||||
node_not_found: (hostname: string) => ({
|
||||
title: "Node not found",
|
||||
message: `No node found with hostname ${hostname}.`,
|
||||
anchor: "#node-not-found",
|
||||
}),
|
||||
|
||||
user_not_linked: {
|
||||
title: "User account not linked",
|
||||
message:
|
||||
"You'll need to link your user account to a Headscale user before you can use Browser SSH.",
|
||||
anchor: "#user-not-linked",
|
||||
},
|
||||
} as const;
|
||||
|
||||
interface SSHErrorBoundaryProps {
|
||||
title: string;
|
||||
message: string;
|
||||
anchor: string;
|
||||
}
|
||||
|
||||
export function isSSHError(error: unknown): error is SSHErrorBoundaryProps {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"title" in error &&
|
||||
"message" in error &&
|
||||
"anchor" in error &&
|
||||
typeof error.title === "string" &&
|
||||
typeof error.message === "string" &&
|
||||
typeof error.anchor === "string"
|
||||
);
|
||||
}
|
||||
|
||||
const DOCS_BASE = "https://headplane.net/features/ssh";
|
||||
|
||||
export function SSHErrorBoundary({ title, message, anchor }: SSHErrorBoundaryProps) {
|
||||
return (
|
||||
<Card className="w-screen" variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>{title}</Card.Title>
|
||||
<AlertCircle className="mb-2 h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<Card.Text>
|
||||
{message}
|
||||
<br />
|
||||
<br />
|
||||
<Link to={`${DOCS_BASE}${anchor}`} external styled>
|
||||
Headplane SSH Documentation
|
||||
</Link>{" "}
|
||||
</Card.Text>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Restty } from "restty";
|
||||
import type { GhosttyTheme } from "restty";
|
||||
import type { PtyTransport } from "restty/internal";
|
||||
|
||||
import type { HeadplaneSSH, TunnelSession } from "./wasm.client";
|
||||
|
||||
const FONT_BASE = `${__PREFIX__}/fonts`;
|
||||
|
||||
// Ghostty's default canvas background is rgb(20,23,26) — a dark gray, not black.
|
||||
// Override it so the terminal matches the page and pane container backgrounds.
|
||||
const HEADPLANE_THEME: GhosttyTheme = {
|
||||
colors: {
|
||||
background: { r: 0, g: 0, b: 0 },
|
||||
foreground: { r: 235, g: 237, b: 242 },
|
||||
palette: [],
|
||||
},
|
||||
raw: {},
|
||||
};
|
||||
|
||||
function createSSHTransport(ssh: HeadplaneSSH, ipAddress: string, username: string): PtyTransport {
|
||||
let session: TunnelSession | null = null;
|
||||
|
||||
return {
|
||||
connect(options) {
|
||||
session = ssh.openTunnel({
|
||||
ipAddress,
|
||||
username,
|
||||
onData: (data) => options.callbacks.onData?.(data),
|
||||
onConnect: () => options.callbacks.onConnect?.(),
|
||||
onDisconnect: () => {
|
||||
options.callbacks.onDisconnect?.();
|
||||
session = null;
|
||||
},
|
||||
});
|
||||
|
||||
if (options.cols && options.rows) {
|
||||
session.resize(options.cols, options.rows);
|
||||
}
|
||||
},
|
||||
disconnect() {
|
||||
session?.close();
|
||||
session = null;
|
||||
},
|
||||
sendInput(data) {
|
||||
session?.writeInput(data);
|
||||
return session != null;
|
||||
},
|
||||
resize(cols, rows) {
|
||||
session?.resize(cols, rows);
|
||||
return session != null;
|
||||
},
|
||||
isConnected() {
|
||||
return session != null;
|
||||
},
|
||||
destroy() {
|
||||
session?.close();
|
||||
session = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
interface GhosttyProps {
|
||||
ssh: HeadplaneSSH;
|
||||
ipAddress: string;
|
||||
username: string;
|
||||
onConnected: () => void;
|
||||
}
|
||||
|
||||
export default function Ghostty({ ssh, ipAddress, username, onConnected }: GhosttyProps) {
|
||||
const divRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!divRef.current) return;
|
||||
|
||||
const transport = createSSHTransport(ssh, ipAddress, username);
|
||||
const restty = new Restty({
|
||||
root: divRef.current,
|
||||
createInitialPane: true,
|
||||
defaultContextMenu: false,
|
||||
shortcuts: false,
|
||||
searchUi: false,
|
||||
paneStyles: {
|
||||
inactivePaneOpacity: 1,
|
||||
activePaneOpacity: 1,
|
||||
},
|
||||
appOptions: {
|
||||
fontSize: 20,
|
||||
ligatures: true,
|
||||
fontPreset: "none",
|
||||
fontSources: [
|
||||
{
|
||||
type: "url",
|
||||
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Regular.ttf`,
|
||||
label: "JetBrains Mono Nerd Font",
|
||||
},
|
||||
{
|
||||
type: "url",
|
||||
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Bold.ttf`,
|
||||
label: "JetBrains Mono Nerd Font Bold",
|
||||
},
|
||||
{
|
||||
type: "url",
|
||||
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-Italic.ttf`,
|
||||
label: "JetBrains Mono Nerd Font Italic",
|
||||
},
|
||||
{
|
||||
type: "url",
|
||||
url: `${FONT_BASE}/JetBrainsMonoNLNerdFontMono-BoldItalic.ttf`,
|
||||
label: "JetBrains Mono Nerd Font Bold Italic",
|
||||
},
|
||||
{
|
||||
type: "url",
|
||||
url: `${FONT_BASE}/SymbolsNerdFontMono-Regular.ttf`,
|
||||
label: "Symbols Nerd Font",
|
||||
},
|
||||
],
|
||||
ptyTransport: transport,
|
||||
callbacks: {
|
||||
onPtyStatus: (status) => {
|
||||
if (status === "connected") onConnected();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
restty.applyTheme(HEADPLANE_THEME);
|
||||
restty.updateSize(true);
|
||||
restty.connectPty();
|
||||
|
||||
return () => {
|
||||
restty.destroy();
|
||||
};
|
||||
}, [ssh, ipAddress, username]);
|
||||
|
||||
return <div className="min-h-0 min-w-0 flex-1 overflow-hidden bg-black" ref={divRef} />;
|
||||
}
|
||||
Vendored
-57
@@ -1,57 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Loader2, WifiOff } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Card from "~/components/card";
|
||||
import Code from "~/components/code";
|
||||
import { findHeadscaleUserBySubject } from "~/server/web/headscale-identity";
|
||||
|
||||
import type { Route } from "./+types/page";
|
||||
import { isSSHError, SSHErrorBoundary, sshErrors } from "./errors";
|
||||
import Ghostty from "./ghostty.client";
|
||||
import UserPrompt from "./user-prompt";
|
||||
import type { HeadplaneSSH } from "./wasm.client";
|
||||
import { loadHeadplaneWASM } from "./wasm.client";
|
||||
|
||||
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
|
||||
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
|
||||
|
||||
export const shouldRevalidate: ShouldRevalidateFunction = () => {
|
||||
return false;
|
||||
};
|
||||
|
||||
export async function loader({ request, params, context }: Route.LoaderArgs) {
|
||||
const origin = new URL(request.url).origin;
|
||||
const assets = [WASM_HELPER_URL, WASM_MODULE_URL];
|
||||
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(sshErrors.wasm_missing, 405);
|
||||
}
|
||||
|
||||
if (context.agents.state !== "enabled") {
|
||||
throw data(sshErrors.agent_required, 400);
|
||||
}
|
||||
|
||||
const { principal, api } = await context.apiForRequest(request);
|
||||
if (principal.kind === "api_key") {
|
||||
throw data(sshErrors.oidc_required, 403);
|
||||
}
|
||||
|
||||
const hostname = params.id;
|
||||
const username = new URL(request.url).searchParams.get("user") || undefined;
|
||||
|
||||
const nodes = await api.nodes.list();
|
||||
const node = nodes.find((n) => n.givenName === hostname);
|
||||
if (!node) {
|
||||
throw data(sshErrors.node_not_found(hostname), 404);
|
||||
}
|
||||
|
||||
if (!node.online) {
|
||||
return { hostname, username, offline: true, node: undefined };
|
||||
}
|
||||
|
||||
if (!username) {
|
||||
return { hostname, username: undefined, offline: false, node: undefined };
|
||||
}
|
||||
|
||||
// The user must exist within Headscale to generate a pre-auth key
|
||||
const users = await api.users.list();
|
||||
const hsUser = findHeadscaleUserBySubject(users, principal.user.subject, principal.profile.email);
|
||||
|
||||
if (!hsUser) {
|
||||
throw data(sshErrors.user_not_linked, 404);
|
||||
}
|
||||
|
||||
const preAuthKey = await api.preAuthKeys.create({
|
||||
user: hsUser.id,
|
||||
ephemeral: true,
|
||||
reusable: false,
|
||||
expiration: new Date(Date.now() + 60 * 1000), // 1 minute expiry
|
||||
aclTags: null,
|
||||
});
|
||||
|
||||
const controlURL = context.config.headscale.public_url ?? context.config.headscale.url;
|
||||
return {
|
||||
hostname,
|
||||
username,
|
||||
offline: false,
|
||||
node: {
|
||||
ipAddress: node.ipAddresses[0],
|
||||
controlURL,
|
||||
preAuthKey: preAuthKey.key,
|
||||
ephemeralHostname: generateHostname(username),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function generateHostname(username: string) {
|
||||
const hex = crypto.randomUUID().replace(/-/g, "").slice(0, 8);
|
||||
return `ssh-${hex}-${username}`;
|
||||
}
|
||||
|
||||
export const links: Route.LinksFunction = () => [
|
||||
{
|
||||
rel: "preload",
|
||||
href: WASM_MODULE_URL,
|
||||
as: "fetch",
|
||||
type: "application/wasm",
|
||||
crossOrigin: "anonymous",
|
||||
},
|
||||
];
|
||||
|
||||
export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
const { hostname, username, offline, node } = loaderData;
|
||||
|
||||
if (offline) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-black">
|
||||
<Card className="w-screen" variant="flat">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Card.Title>Node Offline</Card.Title>
|
||||
<WifiOff className="mb-2 h-6 w-6 text-red-500" />
|
||||
</div>
|
||||
<Card.Text>
|
||||
<Code>{hostname}</Code> is not currently connected to the Tailnet.
|
||||
</Card.Text>
|
||||
<Button className="mt-8 w-full" onClick={() => window.location.reload()}>
|
||||
Retry Connection
|
||||
</Button>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!username || !node) {
|
||||
return <UserPrompt hostname={hostname} />;
|
||||
}
|
||||
|
||||
return <SSHConsole hostname={hostname} username={username} node={node} />;
|
||||
}
|
||||
|
||||
function SSHConsole({
|
||||
hostname,
|
||||
username,
|
||||
node,
|
||||
}: {
|
||||
hostname: string;
|
||||
username: string;
|
||||
node: { ipAddress: string; controlURL: string; preAuthKey: string; ephemeralHostname: string };
|
||||
}) {
|
||||
const [ssh, setSsh] = useState<HeadplaneSSH | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [status, setStatus] = useState("Starting tunnel…");
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
console.log("[ssh] Loading WASM factory");
|
||||
loadHeadplaneWASM().then((create) => {
|
||||
console.log("[ssh] Factory loaded, creating IPN", create);
|
||||
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("Joining Tailnet…");
|
||||
const instance = create({
|
||||
controlURL: node.controlURL,
|
||||
preAuthKey: node.preAuthKey,
|
||||
hostname: node.ephemeralHostname,
|
||||
onReady: () => {
|
||||
console.log("[ssh] IPN ready (Running)");
|
||||
if (!cancelled) {
|
||||
setStatus(`Connecting to ${hostname}…`);
|
||||
setSsh(instance);
|
||||
}
|
||||
},
|
||||
onError: (msg) => console.error("[ssh] IPN error:", msg),
|
||||
});
|
||||
|
||||
console.log("[ssh] IPN instance created", instance);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [node]);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 flex flex-col bg-black">
|
||||
{!connected && (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="size-8 animate-spin text-mist-200" />
|
||||
<p className="text-sm text-mist-400">{status}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ssh && (
|
||||
<Ghostty
|
||||
ssh={ssh}
|
||||
username={username}
|
||||
ipAddress={node.ipAddress}
|
||||
onConnected={() => setConnected(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
const routeError = isRouteErrorResponse(error) ? error.data : null;
|
||||
if (routeError == null || !isSSHError(routeError)) {
|
||||
// Pass through further down the tree to the global error boundary
|
||||
throw error;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center">
|
||||
<SSHErrorBoundary
|
||||
title={routeError.title}
|
||||
message={routeError.message}
|
||||
anchor={routeError.anchor}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,16 @@
|
||||
import { useState } from "react";
|
||||
import { Form } from "react-router";
|
||||
|
||||
import Button from "~/components/button";
|
||||
import Card from "~/components/card";
|
||||
import Code from "~/components/code";
|
||||
import Input from "~/components/input";
|
||||
import Link from "~/components/link";
|
||||
|
||||
interface UserPromptProps {
|
||||
hostname: string;
|
||||
}
|
||||
|
||||
export default function UserPrompt({ hostname }: UserPromptProps) {
|
||||
const [username, setUsername] = useState("");
|
||||
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center">
|
||||
<Card>
|
||||
@@ -19,27 +18,47 @@ export default function UserPrompt({ hostname }: UserPromptProps) {
|
||||
<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.
|
||||
SSH via the web follows the same ACL rules as regular SSH access in Headscale, so only
|
||||
permitted usernames will work.
|
||||
<br />
|
||||
<br />
|
||||
See the{" "}
|
||||
<Link external styled to="https://headplane.net/features/ssh#troubleshooting">
|
||||
troubleshooting guide
|
||||
</Link>{" "}
|
||||
for common errors.
|
||||
</Card.Text>
|
||||
<Input
|
||||
labelHidden
|
||||
type="text"
|
||||
label="Username"
|
||||
placeholder="Username"
|
||||
className="mb-2"
|
||||
onChange={setUsername}
|
||||
/>
|
||||
<Button
|
||||
variant="heavy"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
// 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}`;
|
||||
<Form
|
||||
method="GET"
|
||||
onSubmit={(e) => {
|
||||
const formData = new FormData(e.currentTarget);
|
||||
const username = formData.get("user");
|
||||
if (!username) {
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
// We have to do a full navigation, since the page needs a full
|
||||
// reload to initialize the SSH connection due to us disabling the
|
||||
// revalidator.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("user", username.toString());
|
||||
window.location.assign(url.toString());
|
||||
}}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
<Input
|
||||
labelHidden
|
||||
type="text"
|
||||
label="Username"
|
||||
name="user"
|
||||
placeholder="Username"
|
||||
className="mb-2"
|
||||
required
|
||||
/>
|
||||
<Button type="submit" variant="heavy" className="w-full">
|
||||
Connect
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
|
||||
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
|
||||
|
||||
declare global {
|
||||
type HeadplaneSSHFactory = (config: HeadplaneSSHConfig) => HeadplaneSSH;
|
||||
var __hp_ssh_resolve: ((factory: HeadplaneSSHFactory) => void) | undefined;
|
||||
|
||||
var Go: {
|
||||
new (): {
|
||||
importObject: WebAssembly.Imports;
|
||||
run(instance: WebAssembly.Instance): Promise<void>;
|
||||
argv?: string[];
|
||||
env?: Record<string, string>;
|
||||
exit?: (code: number) => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
interface HeadplaneSSHConfig {
|
||||
controlURL: string;
|
||||
preAuthKey: string;
|
||||
hostname: string;
|
||||
onReady: () => void;
|
||||
onError?: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface HeadplaneSSH {
|
||||
openTunnel(config: TunnelConfig): TunnelSession;
|
||||
}
|
||||
|
||||
interface TunnelConfig {
|
||||
ipAddress: string;
|
||||
username: string;
|
||||
timeout?: number;
|
||||
onData: (data: string) => void;
|
||||
onConnect: () => void;
|
||||
onDisconnect: () => void;
|
||||
}
|
||||
|
||||
export interface TunnelSession {
|
||||
writeInput(data: string): void;
|
||||
resize(cols: number, rows: number): void;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
let resolvedFactory: Promise<HeadplaneSSHFactory> | null = null;
|
||||
|
||||
function loadGoHelper(): Promise<void> {
|
||||
if (typeof globalThis.Go !== "undefined") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const script = document.createElement("script");
|
||||
script.src = WASM_HELPER_URL;
|
||||
script.crossOrigin = "anonymous";
|
||||
script.onload = () => resolve();
|
||||
script.onerror = () => reject(new Error("Failed to load Go WASM helper"));
|
||||
document.head.appendChild(script);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot function that loads the Go WASM binary and returns the SSH factory.
|
||||
* Automatically loads the Go JS helper if it hasn't been loaded yet.
|
||||
*/
|
||||
export async function loadHeadplaneWASM(): Promise<HeadplaneSSHFactory> {
|
||||
if (!resolvedFactory) {
|
||||
await loadGoHelper();
|
||||
|
||||
const go = new Go();
|
||||
const result = await WebAssembly.instantiateStreaming(fetch(WASM_MODULE_URL), go.importObject);
|
||||
|
||||
resolvedFactory = new Promise<HeadplaneSSHFactory>((resolve) => {
|
||||
globalThis.__hp_ssh_resolve = resolve;
|
||||
});
|
||||
|
||||
go.run(result.instance);
|
||||
}
|
||||
|
||||
return resolvedFactory;
|
||||
}
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
declare class Go {
|
||||
importObject: WebAssembly.Imports;
|
||||
run(instance: WebAssembly.Instance): Promise<void>;
|
||||
argv?: string[];
|
||||
env?: Record<string, string>;
|
||||
exit?: (code: number) => void;
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
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 z-50 mx-auto flex h-screen w-screen items-center justify-center">
|
||||
<Loader2 className="size-10 animate-spin text-mist-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-mist-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-mist-800 text-white rounded-full shadow z-50",
|
||||
)}
|
||||
>
|
||||
Failed to connect to SSH session
|
||||
{error}
|
||||
</div>
|
||||
) : undefined}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -30,8 +30,8 @@ export default function HeadplaneUserRow({
|
||||
const displayEmail = user.linkedHeadscaleUser?.email ?? user.email;
|
||||
|
||||
return (
|
||||
<tr className="group hover:bg-mist-50 dark:hover:bg-mist-950" key={user.id}>
|
||||
<td className="py-2 pl-0.5">
|
||||
<tr className="group hover:bg-mist-100 dark:hover:bg-mist-800" key={user.id}>
|
||||
<td className="py-2 pl-2">
|
||||
<div className="flex items-center">
|
||||
{user.profilePicUrl ? (
|
||||
<img alt={displayName} className="h-10 w-10 rounded-full" src={user.profilePicUrl} />
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Ellipsis } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { Menu, MenuContent, MenuItem, MenuSeparator, MenuTrigger } from "~/components/menu";
|
||||
|
||||
import Delete from "../dialogs/delete-user";
|
||||
import Rename from "../dialogs/rename-user";
|
||||
import type { UnlinkedHeadscaleUser } from "../overview";
|
||||
|
||||
interface HeadscaleUserMenuProps {
|
||||
user: UnlinkedHeadscaleUser;
|
||||
}
|
||||
|
||||
type Modal = "rename" | "delete" | null;
|
||||
|
||||
export default function HeadscaleUserMenu({ user }: HeadscaleUserMenuProps) {
|
||||
const [modal, setModal] = useState<Modal>(null);
|
||||
|
||||
// Headscale-managed OIDC users cannot be renamed via the API.
|
||||
const canRename = user.provider !== "oidc";
|
||||
|
||||
return (
|
||||
<>
|
||||
{modal === "rename" && canRename && (
|
||||
<Rename
|
||||
isOpen={modal === "rename"}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
{modal === "delete" && (
|
||||
<Delete
|
||||
isOpen={modal === "delete"}
|
||||
machines={user.machines}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
user={user}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Menu>
|
||||
<MenuTrigger className="w-10 rounded-full bg-transparent p-1 py-0.5 hover:bg-mist-100 dark:hover:bg-mist-800">
|
||||
<Ellipsis className="h-5" />
|
||||
</MenuTrigger>
|
||||
<MenuContent>
|
||||
{canRename && <MenuItem onClick={() => setModal("rename")}>Rename</MenuItem>}
|
||||
{canRename && <MenuSeparator />}
|
||||
<MenuItem variant="danger" onClick={() => setModal("delete")}>
|
||||
Delete
|
||||
</MenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,12 +4,14 @@ import StatusCircle from "~/components/status-circle";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
import type { UnlinkedHeadscaleUser } from "../overview";
|
||||
import HeadscaleUserMenu from "./headscale-user-menu";
|
||||
|
||||
interface HeadscaleUserRowProps {
|
||||
user: UnlinkedHeadscaleUser;
|
||||
writable?: boolean;
|
||||
}
|
||||
|
||||
export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
|
||||
export default function HeadscaleUserRow({ user, writable }: HeadscaleUserRowProps) {
|
||||
const isOnline = user.machines.some((machine) => machine.online);
|
||||
const lastSeen = user.machines.reduce(
|
||||
(acc, machine) => Math.max(acc, new Date(machine.lastSeen).getTime()),
|
||||
@@ -54,9 +56,7 @@ export default function HeadscaleUserRow({ user }: HeadscaleUserRowProps) {
|
||||
<p className="text-sm text-mist-600 dark:text-mist-300">No machines</p>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">
|
||||
{/* Unlinked users only get basic Headscale operations (rename, delete) */}
|
||||
</td>
|
||||
<td className="py-2 pr-0.5">{writable ? <HeadscaleUserMenu user={user} /> : null}</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,24 +54,24 @@ export default function UserMenu({
|
||||
{modal === "reassign" && (
|
||||
<Reassign
|
||||
displayName={displayName}
|
||||
headplaneUserId={user.id}
|
||||
isOpen={modal === "reassign"}
|
||||
role={user.role}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
userId={user.linkedHeadscaleUser?.id ?? user.id}
|
||||
/>
|
||||
)}
|
||||
{modal === "link" && (
|
||||
<LinkUser
|
||||
currentLink={currentLink}
|
||||
displayName={displayName}
|
||||
headplaneUserId={user.id}
|
||||
headscaleUsers={linkableUsers}
|
||||
isOpen={modal === "link"}
|
||||
setIsOpen={(isOpen) => {
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
userId={user.linkedHeadscaleUser?.id ?? user.id}
|
||||
/>
|
||||
)}
|
||||
{modal === "transfer" && (
|
||||
@@ -81,7 +81,7 @@ export default function UserMenu({
|
||||
if (!isOpen) setModal(null);
|
||||
}}
|
||||
targetDisplayName={displayName}
|
||||
targetUserId={user.linkedHeadscaleUser?.id ?? user.id}
|
||||
targetHeadplaneUserId={user.id}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ export default function DeleteUser({ user, machines, isOpen, setIsOpen }: Delete
|
||||
</Text>
|
||||
)}
|
||||
<input name="action_id" type="hidden" value="delete_user" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<input name="headscale_user_id" type="hidden" value={user.id} />
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -5,7 +5,7 @@ import Title from "~/components/title";
|
||||
import cn from "~/utils/cn";
|
||||
|
||||
interface LinkUserProps {
|
||||
userId: string;
|
||||
headplaneUserId: string;
|
||||
displayName: string;
|
||||
headscaleUsers: { id: string; name: string }[];
|
||||
currentLink?: string;
|
||||
@@ -14,7 +14,7 @@ interface LinkUserProps {
|
||||
}
|
||||
|
||||
export default function LinkUser({
|
||||
userId,
|
||||
headplaneUserId,
|
||||
displayName,
|
||||
headscaleUsers,
|
||||
currentLink,
|
||||
@@ -34,7 +34,7 @@ export default function LinkUser({
|
||||
) : (
|
||||
<>
|
||||
<input name="action_id" type="hidden" value="link_user" />
|
||||
<input name="user_id" type="hidden" value={userId} />
|
||||
<input name="headplane_user_id" type="hidden" value={headplaneUserId} />
|
||||
<select
|
||||
className={cn(
|
||||
"w-full rounded-lg border p-2",
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Roles } from "~/server/web/roles";
|
||||
import type { Role } from "~/server/web/roles";
|
||||
|
||||
interface ReassignProps {
|
||||
userId: string;
|
||||
headplaneUserId: string;
|
||||
displayName: string;
|
||||
role: Role;
|
||||
isOpen: boolean;
|
||||
@@ -16,7 +16,7 @@ interface ReassignProps {
|
||||
}
|
||||
|
||||
export default function ReassignUser({
|
||||
userId,
|
||||
headplaneUserId,
|
||||
displayName,
|
||||
role,
|
||||
isOpen,
|
||||
@@ -38,7 +38,7 @@ export default function ReassignUser({
|
||||
) : (
|
||||
<>
|
||||
<input name="action_id" type="hidden" value="reassign_user" />
|
||||
<input name="user_id" type="hidden" value={userId} />
|
||||
<input name="headplane_user_id" type="hidden" value={headplaneUserId} />
|
||||
<RadioGroup className="gap-4" defaultValue={role} label="Role" name="new_role">
|
||||
{Object.keys(Roles)
|
||||
.filter((r) => r !== "owner")
|
||||
|
||||
@@ -21,7 +21,7 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
|
||||
update any ACL policies that may refer to this user by their old username.
|
||||
</Text>
|
||||
<input name="action_id" type="hidden" value="rename_user" />
|
||||
<input name="user_id" type="hidden" value={user.id} />
|
||||
<input name="headscale_user_id" type="hidden" value={user.id} />
|
||||
<Input
|
||||
defaultValue={user.name}
|
||||
required
|
||||
|
||||
@@ -4,14 +4,14 @@ import Text from "~/components/text";
|
||||
import Title from "~/components/title";
|
||||
|
||||
interface TransferOwnershipProps {
|
||||
targetUserId: string;
|
||||
targetHeadplaneUserId: string;
|
||||
targetDisplayName: string;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
export default function TransferOwnership({
|
||||
targetUserId,
|
||||
targetHeadplaneUserId,
|
||||
targetDisplayName,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
@@ -29,7 +29,7 @@ export default function TransferOwnership({
|
||||
ownership.
|
||||
</Notice>
|
||||
<input name="action_id" type="hidden" value="transfer_ownership" />
|
||||
<input name="user_id" type="hidden" value={targetUserId} />
|
||||
<input name="headplane_user_id" type="hidden" value={targetHeadplaneUserId} />
|
||||
</DialogPanel>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -54,8 +54,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
let apiError: string | undefined;
|
||||
|
||||
try {
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
const [nodesSnap, usersSnap] = await Promise.all([
|
||||
context.hsLive.get(nodesResource, api),
|
||||
context.hsLive.get(usersResource, api),
|
||||
@@ -235,7 +234,7 @@ export default function Page({ loaderData }: Route.ComponentProps) {
|
||||
)}
|
||||
>
|
||||
{loaderData.unlinkedHeadscaleUsers.map((user) => (
|
||||
<HeadscaleUserRow key={user.id} user={user} />
|
||||
<HeadscaleUserRow key={user.id} user={user} writable={loaderData.writable} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { data } from "react-router";
|
||||
|
||||
import { usersResource } from "~/server/headscale/live-store";
|
||||
import { getOidcSubject } from "~/server/web/headscale-identity";
|
||||
import { Capabilities } from "~/server/web/roles";
|
||||
import type { Role } from "~/server/web/roles";
|
||||
|
||||
@@ -24,8 +23,7 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
switch (action) {
|
||||
case "create_user": {
|
||||
const name = formData.get("username")?.toString();
|
||||
@@ -38,33 +36,33 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
await api.createUser(name, email, displayName);
|
||||
await api.users.create({ name, email, displayName });
|
||||
await context.hsLive.refresh(usersResource, api);
|
||||
return { message: "User created successfully" };
|
||||
}
|
||||
case "delete_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
if (!userId) {
|
||||
throw data("Missing `user_id` in the form data.", {
|
||||
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||
if (!headscaleUserId) {
|
||||
throw data("Missing `headscale_user_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
await api.deleteUser(userId);
|
||||
await api.users.delete(headscaleUserId);
|
||||
await context.hsLive.refresh(usersResource, api);
|
||||
return { message: "User deleted successfully" };
|
||||
}
|
||||
case "rename_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||
const newName = formData.get("new_name")?.toString();
|
||||
if (!userId || !newName) {
|
||||
if (!headscaleUserId || !newName) {
|
||||
return data({ success: false }, 400);
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
const users = await api.users.list({ id: headscaleUserId });
|
||||
const user = users.find((user) => user.id === headscaleUserId);
|
||||
if (!user) {
|
||||
throw data(`No user found with id: ${userId}`, { status: 400 });
|
||||
throw data(`No user found with id: ${headscaleUserId}`, { status: 400 });
|
||||
}
|
||||
|
||||
if (user.provider === "oidc") {
|
||||
@@ -74,34 +72,20 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
});
|
||||
}
|
||||
|
||||
await api.renameUser(userId, newName);
|
||||
await api.users.rename(headscaleUserId, newName);
|
||||
await context.hsLive.refresh(usersResource, api);
|
||||
return { message: "User renamed successfully" };
|
||||
}
|
||||
case "reassign_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
const headplaneUserId = formData.get("headplane_user_id")?.toString();
|
||||
const newRole = formData.get("new_role")?.toString();
|
||||
if (!userId || !newRole) {
|
||||
throw data("Missing `user_id` or `new_role` in the form data.", {
|
||||
if (!headplaneUserId || !newRole) {
|
||||
throw data("Missing `headplane_user_id` or `new_role` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data("Specified user not found", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const subject = getOidcSubject(user);
|
||||
if (!subject) {
|
||||
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
|
||||
}
|
||||
|
||||
const result = await context.auth.reassignSubject(subject, newRole as Role);
|
||||
|
||||
const result = await context.auth.reassignUser(headplaneUserId, newRole as Role);
|
||||
if (!result) {
|
||||
throw data("Failed to reassign user role.", { status: 500 });
|
||||
}
|
||||
@@ -113,23 +97,12 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
throw data("Only the owner can transfer ownership.", { status: 403 });
|
||||
}
|
||||
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
if (!userId) {
|
||||
throw data("Missing `user_id` in the form data.", { status: 400 });
|
||||
const headplaneUserId = formData.get("headplane_user_id")?.toString();
|
||||
if (!headplaneUserId) {
|
||||
throw data("Missing `headplane_user_id` in the form data.", { status: 400 });
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data("Specified user not found", { status: 400 });
|
||||
}
|
||||
|
||||
const targetSubject = getOidcSubject(user);
|
||||
if (!targetSubject) {
|
||||
throw data("Target user is not an OIDC user or has no subject.", { status: 400 });
|
||||
}
|
||||
|
||||
const result = await context.auth.transferOwnership(principal.user.subject, targetSubject);
|
||||
const result = await context.auth.transferOwnership(principal.user.id, headplaneUserId);
|
||||
if (!result) {
|
||||
throw data("Failed to transfer ownership.", { status: 500 });
|
||||
}
|
||||
@@ -137,26 +110,15 @@ export async function userAction({ request, context }: Route.ActionArgs) {
|
||||
return { message: "Ownership transferred successfully" };
|
||||
}
|
||||
case "link_user": {
|
||||
const userId = formData.get("user_id")?.toString();
|
||||
const headplaneUserId = formData.get("headplane_user_id")?.toString();
|
||||
const headscaleUserId = formData.get("headscale_user_id")?.toString();
|
||||
if (!userId || !headscaleUserId) {
|
||||
throw data("Missing `user_id` or `headscale_user_id` in the form data.", {
|
||||
if (!headplaneUserId || !headscaleUserId) {
|
||||
throw data("Missing `headplane_user_id` or `headscale_user_id` in the form data.", {
|
||||
status: 400,
|
||||
});
|
||||
}
|
||||
|
||||
const users = await api.getUsers(userId);
|
||||
const user = users.find((user) => user.id === userId);
|
||||
if (!user) {
|
||||
throw data("Specified user not found", { status: 400 });
|
||||
}
|
||||
|
||||
const subject = getOidcSubject(user);
|
||||
if (!subject) {
|
||||
throw data("Specified user is not an OIDC user or has no subject.", { status: 400 });
|
||||
}
|
||||
|
||||
const linked = await context.auth.linkHeadscaleUserBySubject(subject, headscaleUserId);
|
||||
const linked = await context.auth.linkHeadscaleUser(headplaneUserId, headscaleUserId);
|
||||
if (!linked) {
|
||||
throw data("That Headscale user is already linked to another account.", { status: 409 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { data, redirect } from "react-router";
|
||||
|
||||
import { isValidColorScheme, setColorScheme } from "~/utils/color-scheme";
|
||||
|
||||
import type { Route } from "./+types/color-scheme";
|
||||
|
||||
export async function action({ request }: Route.ActionArgs) {
|
||||
const formData = await request.formData();
|
||||
const colorScheme = formData.get("colorScheme");
|
||||
const returnTo = safeRedirect(formData.get("returnTo"));
|
||||
|
||||
if (!colorScheme || !isValidColorScheme(colorScheme)) {
|
||||
throw data("Bad Request", { status: 400 });
|
||||
}
|
||||
|
||||
return redirect(returnTo, {
|
||||
headers: {
|
||||
"Set-Cookie": await setColorScheme(colorScheme),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Stolen from react-router thanks!
|
||||
function safeRedirect(to: FormDataEntryValue | null) {
|
||||
if (!to || typeof to !== "string") {
|
||||
return "/";
|
||||
}
|
||||
|
||||
if (!to.startsWith("/") || to.startsWith("//")) {
|
||||
return "/";
|
||||
}
|
||||
|
||||
return to;
|
||||
}
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { Route } from './+types/healthz';
|
||||
import type { Route } from "./+types/healthz";
|
||||
|
||||
export async function loader({ context }: Route.LoaderArgs) {
|
||||
// Use a fake API key for healthcheck
|
||||
const api = context.hsApi.getRuntimeClient('fake-api-key');
|
||||
const healthy = await api.isHealthy();
|
||||
const healthy = await context.headscale.health();
|
||||
|
||||
return new Response(JSON.stringify({ status: healthy ? 'OK' : 'ERROR' }), {
|
||||
status: healthy ? 200 : 500,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return new Response(JSON.stringify({ status: healthy ? "OK" : "ERROR" }), {
|
||||
status: healthy ? 200 : 500,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
+51
-51
@@ -1,59 +1,59 @@
|
||||
import { versions } from 'node:process';
|
||||
import { data } from 'react-router';
|
||||
import type { Route } from './+types/info';
|
||||
import { versions } from "node:process";
|
||||
|
||||
import { data } from "react-router";
|
||||
|
||||
import type { Route } from "./+types/info";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
if (context.config.server.info_secret == null) {
|
||||
throw data(
|
||||
{
|
||||
status: 'Forbidden',
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
if (context.config.server.info_secret == null) {
|
||||
throw data(
|
||||
{
|
||||
status: "Forbidden",
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
const bearer = request.headers.get('Authorization') ?? '';
|
||||
if (!bearer.startsWith('Bearer ')) {
|
||||
throw data(
|
||||
{
|
||||
status: 'Unauthorized',
|
||||
},
|
||||
401,
|
||||
);
|
||||
}
|
||||
const bearer = request.headers.get("Authorization") ?? "";
|
||||
if (!bearer.startsWith("Bearer ")) {
|
||||
throw data(
|
||||
{
|
||||
status: "Unauthorized",
|
||||
},
|
||||
401,
|
||||
);
|
||||
}
|
||||
|
||||
const token = bearer.slice('Bearer '.length).trim();
|
||||
if (token !== context.config.server.info_secret) {
|
||||
throw data(
|
||||
{
|
||||
status: 'Forbidden',
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
const token = bearer.slice("Bearer ".length).trim();
|
||||
if (token !== context.config.server.info_secret) {
|
||||
throw data(
|
||||
{
|
||||
status: "Forbidden",
|
||||
},
|
||||
403,
|
||||
);
|
||||
}
|
||||
|
||||
// Use a fake API key for healthcheck
|
||||
const api = context.hsApi.getRuntimeClient('fake-api-key');
|
||||
const healthy = await api.isHealthy();
|
||||
const healthy = await context.headscale.health();
|
||||
|
||||
const body = {
|
||||
status: healthy ? 'healthy' : 'unhealthy',
|
||||
headplane_version: __VERSION__,
|
||||
headscale_canonical_version: healthy ? context.hsApi.apiVersion : 'unknown',
|
||||
internal_versions: {
|
||||
node: versions.node,
|
||||
v8: versions.v8,
|
||||
uv: versions.uv,
|
||||
zlib: versions.zlib,
|
||||
openssl: versions.openssl,
|
||||
libc: versions.libc,
|
||||
},
|
||||
};
|
||||
const body = {
|
||||
status: healthy ? "healthy" : "unhealthy",
|
||||
headplane_version: __VERSION__,
|
||||
headscale_canonical_version: healthy ? context.headscale.version.raw : "unknown",
|
||||
internal_versions: {
|
||||
node: versions.node,
|
||||
v8: versions.v8,
|
||||
uv: versions.uv,
|
||||
zlib: versions.zlib,
|
||||
openssl: versions.openssl,
|
||||
libc: versions.libc,
|
||||
},
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,9 +4,7 @@ import log from "~/utils/log";
|
||||
import type { Route } from "./+types/live";
|
||||
|
||||
export async function loader({ request, context }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(request);
|
||||
const apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const { api } = await context.apiForRequest(request);
|
||||
|
||||
// Ensure resources are loaded before streaming
|
||||
await Promise.all([
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from 'react-router';
|
||||
import { redirect } from "react-router";
|
||||
|
||||
export async function loader() {
|
||||
return redirect('/machines');
|
||||
return redirect("/machines");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
# `app/server/`
|
||||
|
||||
Server-side application code for Headplane. Everything in this directory
|
||||
runs only on the Node process — never in the browser.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
app/server/
|
||||
├── app.ts ← The Headplane application (load context, RR listener)
|
||||
├── main.ts ← Production bootstrap (binds an http(s) server)
|
||||
├── context.ts ← createAppContext() — assembles the AppLoadContext
|
||||
├── result.ts ← Result<T, E> helper used across the server modules
|
||||
│
|
||||
├── config/ ← YAML config loading, schema, env-overrides, integrations
|
||||
├── db/ ← Drizzle SQLite client + schema
|
||||
├── headscale/ ← Headscale REST API client + headscale-config loader
|
||||
├── oidc/ ← OIDC provider abstraction
|
||||
├── web/ ← Authentication service, identity, RBAC capabilities
|
||||
└── hp-agent.ts ← Headplane agent process manager
|
||||
```
|
||||
|
||||
## Entry points
|
||||
|
||||
There are two SSR entries; both are picked up by Vite via `vite.config.ts`.
|
||||
|
||||
### `app.ts` — the application module
|
||||
|
||||
Loads config → builds the `AppLoadContext` (via [`context.ts`](./context.ts))
|
||||
→ exports the React Router `RequestListener` as `default`, plus the
|
||||
resolved `config` as a named export.
|
||||
|
||||
This module has no opinions about how the server is hosted. It does not
|
||||
listen on a socket, doesn't compose static-asset serving, and doesn't
|
||||
handle the basename redirect — that's the runtime's job (see
|
||||
[`runtime/`](../../runtime/)).
|
||||
|
||||
Consumed by:
|
||||
|
||||
- [`main.ts`](./main.ts) in production builds
|
||||
- [`runtime/vite-plugin.ts`](../../runtime/vite-plugin.ts) in `react-router dev`
|
||||
|
||||
### `main.ts` — the production bootstrap
|
||||
|
||||
The SSR build input. Rollup bundles this file into
|
||||
`build/server/index.js`. It:
|
||||
|
||||
1. imports the listener + config from [`app.ts`](./app.ts)
|
||||
2. wraps the listener with `composeListener` from
|
||||
[`runtime/http.ts`](../../runtime/http.ts) — adds `/admin → /admin/`
|
||||
redirect and serves `build/client/` as static assets with immutable
|
||||
caching for the `assets/` subdirectory
|
||||
3. binds an http(s) server with `startHttpServer`
|
||||
|
||||
Run with `node /app/build/server/index.js` (this is what the Dockerfile
|
||||
does). TLS is a one-line addition: pass `tls: { key, cert }` to
|
||||
`startHttpServer`.
|
||||
|
||||
## Application context
|
||||
|
||||
[`context.ts`](./context.ts) exposes `createAppContext(config)`, which
|
||||
constructs everything that needs to live for the lifetime of the
|
||||
process:
|
||||
|
||||
- the SQLite client (`db`)
|
||||
- the Headscale REST interface (`headscale`)
|
||||
- the optional Headplane agent manager (`agents`)
|
||||
- the auth service (`auth`)
|
||||
- the optional OIDC service (`oidc`)
|
||||
- the live store (`hsLive`)
|
||||
- the (best-effort) parsed Headscale config (`hs`)
|
||||
- the integration adapter (`integration`)
|
||||
|
||||
The returned object is the `AppLoadContext` exposed to every React
|
||||
Router loader/action. The module also `declare module "react-router" { interface AppLoadContext extends AppContext {} }`
|
||||
so route handlers get full type inference on `context`.
|
||||
|
||||
When a route needs the type, import it from `~/server/context`:
|
||||
|
||||
```ts
|
||||
import type { AppContext } from "~/server/context";
|
||||
|
||||
export async function loader({ context }: LoaderFunctionArgs<AppContext>) {
|
||||
// …
|
||||
}
|
||||
```
|
||||
|
||||
## Dev vs. prod
|
||||
|
||||
| Concern | Dev (`react-router dev`) | Prod (`node build/server/index.js`) |
|
||||
| ---------------------------------------- | -------------------------------------------------- | ------------------------------------------------------------ |
|
||||
| HTTP server | Vite owns it (`vite.config.ts` `server.host/port`) | `runtime/http.ts` `startHttpServer` |
|
||||
| Static assets | `./public` (served by `runtime/vite-plugin.ts`) | `build/client/` (served by `runtime/http.ts` static handler) |
|
||||
| Basename redirect (`/admin` → `/admin/`) | `runtime/vite-plugin.ts` via `composeListener` | `main.ts` via `composeListener` |
|
||||
| App load (HMR) | `ssrLoadModule(app.ts)` per request | bundled into `build/server/index.js` |
|
||||
| Entry point | [`app.ts`](./app.ts) | [`main.ts`](./main.ts) |
|
||||
|
||||
There is **no** `if (import.meta.env.PROD)` branch in [`app.ts`](./app.ts)
|
||||
or [`main.ts`](./main.ts) — the dev/prod split is expressed by which
|
||||
file is loaded, not by runtime conditionals.
|
||||
|
||||
## Adding a new server-side module
|
||||
|
||||
1. Create the module under an existing subdirectory (or add a new one
|
||||
that names a coherent concern, e.g. `metrics/`, `ratelimit/`).
|
||||
2. If it owns process-lifetime state (a connection pool, a service
|
||||
client, …), construct it in [`context.ts`](./context.ts) and add it
|
||||
to the returned object — this gives every route automatic access via
|
||||
`context.<name>`.
|
||||
3. If it's purely a helper (pure functions, type definitions), import
|
||||
it directly from the module that needs it.
|
||||
@@ -0,0 +1,67 @@
|
||||
// MARK: Headplane Application
|
||||
//
|
||||
// Loads configuration, builds the per-process app context, and exports
|
||||
// the React Router request listener as the default export.
|
||||
//
|
||||
// This module is consumed in two places:
|
||||
// - `app/server/main.ts` — the production bootstrap; wraps the
|
||||
// listener with static-asset serving and binds an http(s) server.
|
||||
// - `runtime/vite-plugin.ts` — the dev-mode Vite middleware; loads
|
||||
// this module through `ssrLoadModule` and dispatches each request.
|
||||
|
||||
import { exit, versions } from "node:process";
|
||||
|
||||
import { createRequestListener } from "@react-router/node";
|
||||
import * as build from "virtual:react-router/server-build";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { HeadplaneConfig } from "./config/config-schema";
|
||||
import { ConfigError } from "./config/error";
|
||||
import { loadConfig } from "./config/load";
|
||||
import { createAppContext } from "./context";
|
||||
|
||||
log.info("server", "Running Node.js %s", versions.node);
|
||||
|
||||
let config: HeadplaneConfig;
|
||||
try {
|
||||
config = await loadConfig();
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigError) {
|
||||
log.error("server", "Unable to load configuration: %s", error.message);
|
||||
} else {
|
||||
log.error("server", "Failed to load configuration: %s", error);
|
||||
}
|
||||
exit(1);
|
||||
}
|
||||
|
||||
if ((config.server.tls_cert_path || config.server.tls_key_path) && !config.server.cookie_secure) {
|
||||
log.warn(
|
||||
"server",
|
||||
"TLS is enabled but `server.cookie_secure` is false; forcing it to true (browsers reject Secure-less cookies over HTTPS)",
|
||||
);
|
||||
config.server.cookie_secure = true;
|
||||
}
|
||||
|
||||
const ctx = await createAppContext(config);
|
||||
ctx.startServices();
|
||||
|
||||
export { config };
|
||||
|
||||
/**
|
||||
* Disposes the per-process context. Invoked by the production
|
||||
* supervisor on SIGTERM/SIGINT and by the dev Vite plugin on HMR
|
||||
* reload.
|
||||
*/
|
||||
export async function dispose(): Promise<void> {
|
||||
await ctx.dispose();
|
||||
}
|
||||
|
||||
// TODO: `getLoadContext` is the right place to handle reverse proxy
|
||||
// translation — better than doing it in the OIDC client because it
|
||||
// applies to all requests, not just OIDC ones.
|
||||
export default createRequestListener({
|
||||
build,
|
||||
mode: import.meta.env.MODE,
|
||||
getLoadContext: () => ctx,
|
||||
});
|
||||
@@ -14,6 +14,23 @@ export const pathSupportedKeys = [
|
||||
"oidc.headscale_api_key",
|
||||
] as const;
|
||||
|
||||
function normalizeStringArray(values: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
|
||||
for (const value of values) {
|
||||
const trimmed = value.trim();
|
||||
if (trimmed.length === 0 || seen.has(trimmed)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(trimmed);
|
||||
normalized.push(trimmed);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const serverConfig = type({
|
||||
host: 'string.ip = "0.0.0.0"',
|
||||
port: "number.integer = 3000",
|
||||
@@ -25,6 +42,12 @@ const serverConfig = type({
|
||||
cookie_secure: "boolean = true",
|
||||
cookie_domain: "string.lower?",
|
||||
cookie_max_age: "number.integer = 86400",
|
||||
|
||||
// TLS termination. When both `tls_cert_path` and `tls_key_path`
|
||||
// are provided, Headplane serves HTTPS on `server.port`. When
|
||||
// either is set, `cookie_secure` is forced to `true`.
|
||||
tls_cert_path: "string?",
|
||||
tls_key_path: "string?",
|
||||
});
|
||||
|
||||
const partialServerConfig = type({
|
||||
@@ -38,6 +61,9 @@ const partialServerConfig = type({
|
||||
cookie_secure: "boolean?",
|
||||
cookie_domain: "string.lower?",
|
||||
cookie_max_age: "number.integer?",
|
||||
|
||||
tls_cert_path: "string?",
|
||||
tls_key_path: "string?",
|
||||
});
|
||||
|
||||
const headscaleConfig = type({
|
||||
@@ -98,12 +124,17 @@ const oidcConfig = type({
|
||||
.optional(),
|
||||
disable_api_key_login: "boolean = false",
|
||||
scope: 'string = "openid email profile"',
|
||||
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
|
||||
allow_weak_rsa_keys: "boolean = false",
|
||||
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
|
||||
extra_params: "Record<string, string>?",
|
||||
|
||||
authorization_endpoint: "string.url?",
|
||||
token_endpoint: "string.url?",
|
||||
userinfo_endpoint: "string.url?",
|
||||
end_session_endpoint: "string.url?",
|
||||
post_logout_redirect_uri: "string.url?",
|
||||
use_end_session: "boolean = false",
|
||||
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
||||
|
||||
// Old/deprecated options
|
||||
@@ -120,12 +151,17 @@ const partialOidcConfig = type({
|
||||
redirect_uri: "string.url?",
|
||||
disable_api_key_login: "boolean?",
|
||||
scope: "string?",
|
||||
subject_claims: type("string[]").pipe(normalizeStringArray).optional(),
|
||||
allow_weak_rsa_keys: "boolean?",
|
||||
extra_params: "Record<string, string>?",
|
||||
profile_picture_source: '"oidc" | "gravatar"?',
|
||||
|
||||
authorization_endpoint: "string.url?",
|
||||
token_endpoint: "string.url?",
|
||||
userinfo_endpoint: "string.url?",
|
||||
end_session_endpoint: "string.url?",
|
||||
post_logout_redirect_uri: "string.url?",
|
||||
use_end_session: "boolean?",
|
||||
token_endpoint_auth_method: '"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
|
||||
|
||||
// Old/deprecated options
|
||||
|
||||
+54
-56
@@ -1,74 +1,72 @@
|
||||
interface ErrorCodes {
|
||||
CONFLICTING_SECRET_PATH_FIELD: {
|
||||
fieldName: string;
|
||||
};
|
||||
CONFLICTING_SECRET_PATH_FIELD: {
|
||||
fieldName: string;
|
||||
};
|
||||
|
||||
INVALID_REQUIRED_FIELDS: {
|
||||
messages: string[];
|
||||
};
|
||||
INVALID_REQUIRED_FIELDS: {
|
||||
messages: string[];
|
||||
};
|
||||
|
||||
MISSING_INTERPOLATION_VARIABLE: {
|
||||
pathKey: string;
|
||||
variableName: string;
|
||||
};
|
||||
MISSING_INTERPOLATION_VARIABLE: {
|
||||
pathKey: string;
|
||||
variableName: string;
|
||||
};
|
||||
|
||||
MISSING_SECRET_FILE: {
|
||||
pathKey: string;
|
||||
filePath: string;
|
||||
};
|
||||
MISSING_SECRET_FILE: {
|
||||
pathKey: string;
|
||||
filePath: string;
|
||||
};
|
||||
}
|
||||
|
||||
const translationsWithVars: {
|
||||
[K in keyof ErrorCodes]: (vars: ErrorCodes[K]) => string;
|
||||
[K in keyof ErrorCodes]: (vars: ErrorCodes[K]) => string;
|
||||
} = {
|
||||
CONFLICTING_SECRET_PATH_FIELD: ({ fieldName }) =>
|
||||
`Both "${fieldName}" and "${fieldName}_path" are set; please provide only one of these fields.`,
|
||||
INVALID_REQUIRED_FIELDS: ({ messages }) =>
|
||||
`The configuration is missing required fields or has invalid values:\n- ${messages.join('\n- ')}`,
|
||||
MISSING_INTERPOLATION_VARIABLE: ({ pathKey, variableName }) =>
|
||||
`Could not resolve environment variable "${variableName}" for configuration key "${pathKey}".`,
|
||||
CONFLICTING_SECRET_PATH_FIELD: ({ fieldName }) =>
|
||||
`Both "${fieldName}" and "${fieldName}_path" are set; please provide only one of these fields.`,
|
||||
INVALID_REQUIRED_FIELDS: ({ messages }) =>
|
||||
`The configuration is missing required fields or has invalid values:\n- ${messages.join("\n- ")}`,
|
||||
MISSING_INTERPOLATION_VARIABLE: ({ pathKey, variableName }) =>
|
||||
`Could not resolve environment variable "${variableName}" for configuration key "${pathKey}".`,
|
||||
|
||||
MISSING_SECRET_FILE: ({ pathKey, filePath }) =>
|
||||
`The secret file specified in "${pathKey}" could not be accessed at path "${filePath}". Please ensure the file exists and is readable.`,
|
||||
MISSING_SECRET_FILE: ({ pathKey, filePath }) =>
|
||||
`The secret file specified in "${pathKey}" could not be accessed at path "${filePath}". Please ensure the file exists and is readable.`,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Custom error class for configuration-related errors.
|
||||
*/
|
||||
export class ConfigError extends Error {
|
||||
/**
|
||||
* The error code representing the type of configuration error.
|
||||
*/
|
||||
code: keyof ErrorCodes;
|
||||
/**
|
||||
* The error code representing the type of configuration error.
|
||||
*/
|
||||
code: keyof ErrorCodes;
|
||||
|
||||
/**
|
||||
* Creates a new ConfigError instance.
|
||||
*
|
||||
* @param code The error code
|
||||
* @param vars The variables to interpolate into the error message
|
||||
*/
|
||||
constructor(code: keyof ErrorCodes, vars: unknown) {
|
||||
super(
|
||||
translationsWithVars[code](
|
||||
vars as (typeof translationsWithVars)[typeof code] extends (
|
||||
vars: infer U,
|
||||
) => string
|
||||
? U
|
||||
: never,
|
||||
),
|
||||
);
|
||||
this.code = code;
|
||||
this.name = 'ConfigError';
|
||||
}
|
||||
/**
|
||||
* Creates a new ConfigError instance.
|
||||
*
|
||||
* @param code The error code
|
||||
* @param vars The variables to interpolate into the error message
|
||||
*/
|
||||
constructor(code: keyof ErrorCodes, vars: unknown) {
|
||||
super(
|
||||
translationsWithVars[code](
|
||||
vars as (typeof translationsWithVars)[typeof code] extends (vars: infer U) => string
|
||||
? U
|
||||
: never,
|
||||
),
|
||||
);
|
||||
this.code = code;
|
||||
this.name = "ConfigError";
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory method to create a ConfigError instance.
|
||||
*
|
||||
* @param code The error code
|
||||
* @param vars The variables to interpolate into the error message
|
||||
* @returns A new ConfigError instance
|
||||
*/
|
||||
static from<K extends keyof ErrorCodes>(code: K, vars: ErrorCodes[K]) {
|
||||
return new ConfigError(code, vars);
|
||||
}
|
||||
/**
|
||||
* Factory method to create a ConfigError instance.
|
||||
*
|
||||
* @param code The error code
|
||||
* @param vars The variables to interpolate into the error message
|
||||
* @returns A new ConfigError instance
|
||||
*/
|
||||
static from<K extends keyof ErrorCodes>(code: K, vars: ErrorCodes[K]) {
|
||||
return new ConfigError(code, vars);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
|
||||
import type { Headscale } from "~/server/headscale/api";
|
||||
|
||||
export abstract class Integration<T> {
|
||||
protected context: NonNullable<T>;
|
||||
constructor(context: T) {
|
||||
if (!context) {
|
||||
throw new Error('Missing integration context');
|
||||
}
|
||||
protected context: NonNullable<T>;
|
||||
constructor(context: T) {
|
||||
if (!context) {
|
||||
throw new Error("Missing integration context");
|
||||
}
|
||||
|
||||
this.context = context;
|
||||
}
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
abstract isAvailable(): Promise<boolean> | boolean;
|
||||
abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void;
|
||||
abstract get name(): string;
|
||||
abstract isAvailable(): Promise<boolean> | boolean;
|
||||
abstract onConfigChange(headscale: Headscale): Promise<void> | void;
|
||||
abstract get name(): string;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import { setTimeout } from "node:timers/promises";
|
||||
import { type } from "arktype";
|
||||
import { Client } from "undici";
|
||||
|
||||
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||
import type { Headscale } from "~/server/headscale/api";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { Integration } from "./abstract";
|
||||
@@ -255,7 +255,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
|
||||
return this.client !== undefined && this.containerId !== undefined;
|
||||
}
|
||||
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
async onConfigChange(headscale: Headscale) {
|
||||
if (!this.client) {
|
||||
return;
|
||||
}
|
||||
@@ -290,7 +290,7 @@ export default class DockerIntegration extends Integration<typeof configSchema.f
|
||||
while (attempts <= this.maxAttempts) {
|
||||
try {
|
||||
log.debug("config", "Checking Headscale status (attempt %d)", attempts);
|
||||
const status = await client.isHealthy();
|
||||
const status = await headscale.health();
|
||||
if (status === false) {
|
||||
throw new Error("Headscale is not running");
|
||||
}
|
||||
|
||||
@@ -1,62 +1,58 @@
|
||||
import log from '~/utils/log';
|
||||
import type { HeadplaneConfig } from '../config-schema';
|
||||
import dockerIntegration from './docker';
|
||||
import kubernetesIntegration from './kubernetes';
|
||||
import procIntegration from './proc';
|
||||
import log from "~/utils/log";
|
||||
|
||||
export async function loadIntegration(context: HeadplaneConfig['integration']) {
|
||||
const integration = getIntegration(context);
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
import type { HeadplaneConfig } from "../config-schema";
|
||||
import dockerIntegration from "./docker";
|
||||
import kubernetesIntegration from "./kubernetes";
|
||||
import procIntegration from "./proc";
|
||||
|
||||
try {
|
||||
const res = await integration.isAvailable();
|
||||
if (!res) {
|
||||
log.error('config', 'Integration %s is not available', integration.name);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error(
|
||||
'config',
|
||||
'Failed to load integration %s: %s',
|
||||
integration,
|
||||
error,
|
||||
);
|
||||
log.debug('config', 'Loading error: %o', error);
|
||||
return;
|
||||
}
|
||||
export async function loadIntegration(context: HeadplaneConfig["integration"]) {
|
||||
const integration = getIntegration(context);
|
||||
if (!integration) {
|
||||
return;
|
||||
}
|
||||
|
||||
return integration;
|
||||
try {
|
||||
const res = await integration.isAvailable();
|
||||
if (!res) {
|
||||
log.error("config", "Integration %s is not available", integration.name);
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
log.error("config", "Failed to load integration %s: %s", integration, error);
|
||||
log.debug("config", "Loading error: %o", error);
|
||||
return;
|
||||
}
|
||||
|
||||
return integration;
|
||||
}
|
||||
|
||||
function getIntegration(integration: HeadplaneConfig['integration']) {
|
||||
const docker = integration?.docker;
|
||||
const k8s = integration?.kubernetes;
|
||||
const proc = integration?.proc;
|
||||
function getIntegration(integration: HeadplaneConfig["integration"]) {
|
||||
const docker = integration?.docker;
|
||||
const k8s = integration?.kubernetes;
|
||||
const proc = integration?.proc;
|
||||
|
||||
if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) {
|
||||
log.debug('config', 'No integrations enabled');
|
||||
return;
|
||||
}
|
||||
if (!docker?.enabled && !k8s?.enabled && !proc?.enabled) {
|
||||
log.debug("config", "No integrations enabled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (docker?.enabled && k8s?.enabled && proc?.enabled) {
|
||||
log.error('config', 'Multiple integrations enabled, please pick one only');
|
||||
return;
|
||||
}
|
||||
if (docker?.enabled && k8s?.enabled && proc?.enabled) {
|
||||
log.error("config", "Multiple integrations enabled, please pick one only");
|
||||
return;
|
||||
}
|
||||
|
||||
if (docker?.enabled) {
|
||||
log.info('config', 'Using Docker integration');
|
||||
return new dockerIntegration(integration!.docker!);
|
||||
}
|
||||
if (docker?.enabled) {
|
||||
log.info("config", "Using Docker integration");
|
||||
return new dockerIntegration(integration!.docker!);
|
||||
}
|
||||
|
||||
if (k8s?.enabled) {
|
||||
log.info('config', 'Using Kubernetes integration');
|
||||
return new kubernetesIntegration(integration!.kubernetes!);
|
||||
}
|
||||
if (k8s?.enabled) {
|
||||
log.info("config", "Using Kubernetes integration");
|
||||
return new kubernetesIntegration(integration!.kubernetes!);
|
||||
}
|
||||
|
||||
if (proc?.enabled) {
|
||||
log.info('config', 'Using Proc integration');
|
||||
return new procIntegration(integration!.proc!);
|
||||
}
|
||||
if (proc?.enabled) {
|
||||
log.info("config", "Using Proc integration");
|
||||
return new procIntegration(integration!.proc!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";
|
||||
import { type } from "arktype";
|
||||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { platform } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||
import { CoreV1Api, KubeConfig } from "@kubernetes/client-node";
|
||||
import { type } from "arktype";
|
||||
|
||||
import type { Headscale } from "~/server/headscale/api";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { Integration } from "./abstract";
|
||||
@@ -154,12 +154,12 @@ export default class KubernetesIntegration extends Integration<typeof configSche
|
||||
}
|
||||
}
|
||||
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
async onConfigChange(headscale: Headscale) {
|
||||
if (!this.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
await signalAndWaitHealthy(client, {
|
||||
await signalAndWaitHealthy(headscale, {
|
||||
pid: this.pid,
|
||||
signal: "SIGHUP",
|
||||
});
|
||||
|
||||
@@ -3,8 +3,7 @@ import { join } from "node:path";
|
||||
import { kill } from "node:process";
|
||||
import { setTimeout } from "node:timers/promises";
|
||||
|
||||
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||
|
||||
import type { Headscale } from "~/server/headscale/api";
|
||||
import log from "~/utils/log";
|
||||
|
||||
/**
|
||||
@@ -76,12 +75,12 @@ export interface SignalHeadscaleOptions {
|
||||
|
||||
/**
|
||||
* Sends a signal to the headscale process and waits for it to become healthy.
|
||||
* @param client The RuntimeApiClient to check health
|
||||
* @param headscale The Headscale instance to health-check
|
||||
* @param options Options for signaling and waiting
|
||||
* @returns True if headscale became healthy, false otherwise
|
||||
*/
|
||||
export async function signalAndWaitHealthy(
|
||||
client: RuntimeApiClient,
|
||||
headscale: Headscale,
|
||||
options: SignalHeadscaleOptions,
|
||||
): Promise<boolean> {
|
||||
const { pid, signal = "SIGHUP", maxAttempts = 10, retryDelayMs = 1000 } = options;
|
||||
@@ -97,7 +96,7 @@ export async function signalAndWaitHealthy(
|
||||
await setTimeout(retryDelayMs);
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
const healthy = await client.isHealthy();
|
||||
const healthy = await headscale.health();
|
||||
if (healthy) {
|
||||
log.info("config", "Headscale is healthy after restart");
|
||||
return true;
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { type } from "arktype";
|
||||
import { platform } from "node:os";
|
||||
|
||||
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
|
||||
import { type } from "arktype";
|
||||
|
||||
import type { Headscale } from "~/server/headscale/api";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import { Integration } from "./abstract";
|
||||
@@ -51,12 +51,12 @@ export default class ProcIntegration extends Integration<typeof configSchema.ful
|
||||
}
|
||||
}
|
||||
|
||||
async onConfigChange(client: RuntimeApiClient) {
|
||||
async onConfigChange(headscale: Headscale) {
|
||||
if (!this.pid) {
|
||||
return;
|
||||
}
|
||||
|
||||
await signalAndWaitHealthy(client, {
|
||||
await signalAndWaitHealthy(headscale, {
|
||||
pid: this.pid,
|
||||
signal: "SIGHUP",
|
||||
});
|
||||
|
||||
+149
-154
@@ -1,14 +1,17 @@
|
||||
import { access, constants, readFile } from 'node:fs/promises';
|
||||
import { type } from 'arktype';
|
||||
import { load } from 'js-yaml';
|
||||
import log from '~/utils/log';
|
||||
import { access, constants, readFile } from "node:fs/promises";
|
||||
|
||||
import { type } from "arktype";
|
||||
import { load } from "js-yaml";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import {
|
||||
headplaneConfig,
|
||||
PartialHeadplaneConfig,
|
||||
partialHeadplaneConfig,
|
||||
pathSupportedKeys,
|
||||
} from './config-schema';
|
||||
import { ConfigError } from './error';
|
||||
headplaneConfig,
|
||||
PartialHeadplaneConfig,
|
||||
partialHeadplaneConfig,
|
||||
pathSupportedKeys,
|
||||
} from "./config-schema";
|
||||
import { ConfigError } from "./error";
|
||||
|
||||
/**
|
||||
* Main entrypoint that attempts to load and merge configuration from both
|
||||
@@ -25,27 +28,27 @@ import { ConfigError } from './error';
|
||||
* @throws {Error} If there are validation errors in the final configuration
|
||||
*/
|
||||
export async function loadConfig(configPathOverride?: string) {
|
||||
const configPath =
|
||||
configPathOverride != null
|
||||
? configPathOverride
|
||||
: process.env.HEADPLANE_CONFIG_PATH != null
|
||||
? String(process.env.HEADPLANE_CONFIG_PATH)
|
||||
: '/etc/headplane/config.yaml';
|
||||
const configPath =
|
||||
configPathOverride != null
|
||||
? configPathOverride
|
||||
: process.env.HEADPLANE_CONFIG_PATH != null
|
||||
? String(process.env.HEADPLANE_CONFIG_PATH)
|
||||
: "/etc/headplane/config.yaml";
|
||||
|
||||
const fileConfig = await loadConfigFile(configPath);
|
||||
const envConfig = await loadConfigEnv();
|
||||
const fileConfig = await loadConfigFile(configPath);
|
||||
const envConfig = await loadConfigEnv();
|
||||
|
||||
const combinedConfig = deepMerge(fileConfig, envConfig);
|
||||
await loadConfigKeyPaths(combinedConfig);
|
||||
const combinedConfig = deepMerge(fileConfig, envConfig);
|
||||
await loadConfigKeyPaths(combinedConfig);
|
||||
|
||||
const finalConfig = headplaneConfig(combinedConfig);
|
||||
if (finalConfig instanceof type.errors) {
|
||||
throw ConfigError.from('INVALID_REQUIRED_FIELDS', {
|
||||
messages: finalConfig.map((e) => e.toString()),
|
||||
});
|
||||
}
|
||||
const finalConfig = headplaneConfig(combinedConfig);
|
||||
if (finalConfig instanceof type.errors) {
|
||||
throw ConfigError.from("INVALID_REQUIRED_FIELDS", {
|
||||
messages: finalConfig.map((e) => e.toString()),
|
||||
});
|
||||
}
|
||||
|
||||
return finalConfig;
|
||||
return finalConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,23 +60,23 @@ export async function loadConfig(configPathOverride?: string) {
|
||||
* @throws {Error} If there are validation errors in the loaded configuration
|
||||
*/
|
||||
export async function loadConfigFile(path: string) {
|
||||
try {
|
||||
await access(path, constants.R_OK);
|
||||
} catch {
|
||||
log.info('config', 'Could not access config file at path: %s', path);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await access(path, constants.R_OK);
|
||||
} catch {
|
||||
log.info("config", "Could not access config file at path: %s", path);
|
||||
return;
|
||||
}
|
||||
|
||||
const rawBuffer = await readFile(path, 'utf8');
|
||||
const rawConfig = load(rawBuffer);
|
||||
const config = partialHeadplaneConfig(rawConfig);
|
||||
if (config instanceof type.errors) {
|
||||
throw ConfigError.from('INVALID_REQUIRED_FIELDS', {
|
||||
messages: config.map((e) => e.toString()),
|
||||
});
|
||||
}
|
||||
const rawBuffer = await readFile(path, "utf8");
|
||||
const rawConfig = load(rawBuffer);
|
||||
const config = partialHeadplaneConfig(rawConfig);
|
||||
if (config instanceof type.errors) {
|
||||
throw ConfigError.from("INVALID_REQUIRED_FIELDS", {
|
||||
messages: config.map((e) => e.toString()),
|
||||
});
|
||||
}
|
||||
|
||||
return config;
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,36 +89,36 @@ export async function loadConfigFile(path: string) {
|
||||
* @throws {Error} If there are validation errors in the loaded configuration
|
||||
*/
|
||||
export async function loadConfigEnv() {
|
||||
if (process.env.HEADPLANE_LOAD_ENV_OVERRIDES != null) {
|
||||
log.warn(
|
||||
'config',
|
||||
'HEADPLANE_LOAD_ENV_OVERRIDES is deprecated and will be removed in future versions',
|
||||
);
|
||||
log.warn(
|
||||
'config',
|
||||
'Environment variables are always loaded and `.env` files are no longer supported',
|
||||
);
|
||||
}
|
||||
if (process.env.HEADPLANE_LOAD_ENV_OVERRIDES != null) {
|
||||
log.warn(
|
||||
"config",
|
||||
"HEADPLANE_LOAD_ENV_OVERRIDES is deprecated and will be removed in future versions",
|
||||
);
|
||||
log.warn(
|
||||
"config",
|
||||
"Environment variables are always loaded and `.env` files are no longer supported",
|
||||
);
|
||||
}
|
||||
|
||||
const rawConfig: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value == null || !key.startsWith('HEADPLANE_')) {
|
||||
continue;
|
||||
}
|
||||
const rawConfig: Record<string, unknown> = {};
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value == null || !key.startsWith("HEADPLANE_")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const parsedValue = parseEnvValue(value);
|
||||
const configKey = key.slice('HEADPLANE_'.length).toLowerCase();
|
||||
deepSet(rawConfig, configKey.split('__'), parsedValue);
|
||||
}
|
||||
const parsedValue = parseEnvValue(value);
|
||||
const configKey = key.slice("HEADPLANE_".length).toLowerCase();
|
||||
deepSet(rawConfig, configKey.split("__"), parsedValue);
|
||||
}
|
||||
|
||||
const config = partialHeadplaneConfig(rawConfig);
|
||||
if (config instanceof type.errors) {
|
||||
throw ConfigError.from('INVALID_REQUIRED_FIELDS', {
|
||||
messages: config.map((e) => e.toString()),
|
||||
});
|
||||
}
|
||||
const config = partialHeadplaneConfig(rawConfig);
|
||||
if (config instanceof type.errors) {
|
||||
throw ConfigError.from("INVALID_REQUIRED_FIELDS", {
|
||||
messages: config.map((e) => e.toString()),
|
||||
});
|
||||
}
|
||||
|
||||
return Object.keys(config).length > 0 ? config : undefined;
|
||||
return Object.keys(config).length > 0 ? config : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,29 +129,25 @@ export async function loadConfigEnv() {
|
||||
* @returns The merged object
|
||||
*/
|
||||
function deepMerge<T>(...objects: (T | undefined)[]): T {
|
||||
const result: { [key: string]: unknown } = {};
|
||||
for (const obj of objects.filter((o) => o != null)) {
|
||||
for (const [key, value] of Object.entries(
|
||||
obj as {
|
||||
[key: string]: unknown;
|
||||
},
|
||||
)) {
|
||||
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
||||
if (
|
||||
result[key] == null ||
|
||||
typeof result[key] !== 'object' ||
|
||||
Array.isArray(result[key])
|
||||
) {
|
||||
result[key] = {};
|
||||
}
|
||||
result[key] = deepMerge(result[key], value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
const result: { [key: string]: unknown } = {};
|
||||
for (const obj of objects.filter((o) => o != null)) {
|
||||
for (const [key, value] of Object.entries(
|
||||
obj as {
|
||||
[key: string]: unknown;
|
||||
},
|
||||
)) {
|
||||
if (value != null && typeof value === "object" && !Array.isArray(value)) {
|
||||
if (result[key] == null || typeof result[key] !== "object" || Array.isArray(result[key])) {
|
||||
result[key] = {};
|
||||
}
|
||||
result[key] = deepMerge(result[key], value);
|
||||
} else {
|
||||
result[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result as T;
|
||||
return result as T;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -158,22 +157,18 @@ function deepMerge<T>(...objects: (T | undefined)[]): T {
|
||||
* @param path An array of keys representing the path to set
|
||||
* @param value The value to set at the specified path
|
||||
*/
|
||||
function deepSet(
|
||||
obj: { [key: string]: unknown },
|
||||
path: string[],
|
||||
value: unknown,
|
||||
): void {
|
||||
let current = obj;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const key = path[i];
|
||||
if (current[key] == null || typeof current[key] !== 'object') {
|
||||
current[key] = {};
|
||||
}
|
||||
function deepSet(obj: { [key: string]: unknown }, path: string[], value: unknown): void {
|
||||
let current = obj;
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const key = path[i];
|
||||
if (current[key] == null || typeof current[key] !== "object") {
|
||||
current[key] = {};
|
||||
}
|
||||
|
||||
current = current[key] as { [key: string]: unknown };
|
||||
}
|
||||
current = current[key] as { [key: string]: unknown };
|
||||
}
|
||||
|
||||
current[path[path.length - 1]] = value;
|
||||
current[path[path.length - 1]] = value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -184,18 +179,18 @@ function deepSet(
|
||||
* @returns The parsed value
|
||||
*/
|
||||
function parseEnvValue(value: string): unknown {
|
||||
const v = value.trim().toLowerCase();
|
||||
if (v === 'true') return true;
|
||||
if (v === 'false') return false;
|
||||
if (v === 'null') return null;
|
||||
if (v === 'undefined') return undefined;
|
||||
const v = value.trim().toLowerCase();
|
||||
if (v === "true") return true;
|
||||
if (v === "false") return false;
|
||||
if (v === "null") return null;
|
||||
if (v === "undefined") return undefined;
|
||||
|
||||
if (/^-?\d+(\.\d+)?$/.test(v)) {
|
||||
const num = Number(v);
|
||||
if (!Number.isNaN(num)) return num;
|
||||
}
|
||||
if (/^-?\d+(\.\d+)?$/.test(v)) {
|
||||
const num = Number(v);
|
||||
if (!Number.isNaN(num)) return num;
|
||||
}
|
||||
|
||||
return value;
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -207,43 +202,43 @@ function parseEnvValue(value: string): unknown {
|
||||
* @param partial The partial configuration object to update
|
||||
*/
|
||||
export async function loadConfigKeyPaths(partial: PartialHeadplaneConfig) {
|
||||
for (const key of pathSupportedKeys) {
|
||||
const pathKey = `${key}_path`;
|
||||
const pathValue = deepGet(partial, pathKey.split('.'));
|
||||
const existing = deepGet(partial, key.split('.'));
|
||||
for (const key of pathSupportedKeys) {
|
||||
const pathKey = `${key}_path`;
|
||||
const pathValue = deepGet(partial, pathKey.split("."));
|
||||
const existing = deepGet(partial, key.split("."));
|
||||
|
||||
if (pathValue == null || typeof pathValue !== 'string') {
|
||||
continue;
|
||||
}
|
||||
if (pathValue == null || typeof pathValue !== "string") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing != null) {
|
||||
throw ConfigError.from('CONFLICTING_SECRET_PATH_FIELD', {
|
||||
fieldName: key,
|
||||
});
|
||||
}
|
||||
if (existing != null) {
|
||||
throw ConfigError.from("CONFLICTING_SECRET_PATH_FIELD", {
|
||||
fieldName: key,
|
||||
});
|
||||
}
|
||||
|
||||
const realPath = pathValue.replace(/\$\{([^}]+)\}/g, (_, variableName) => {
|
||||
const value = process.env[variableName];
|
||||
if (value === undefined) {
|
||||
throw ConfigError.from('MISSING_INTERPOLATION_VARIABLE', {
|
||||
pathKey: `${key}_path`,
|
||||
variableName: variableName,
|
||||
});
|
||||
}
|
||||
const realPath = pathValue.replace(/\$\{([^}]+)\}/g, (_, variableName) => {
|
||||
const value = process.env[variableName];
|
||||
if (value === undefined) {
|
||||
throw ConfigError.from("MISSING_INTERPOLATION_VARIABLE", {
|
||||
pathKey: `${key}_path`,
|
||||
variableName: variableName,
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
});
|
||||
return value;
|
||||
});
|
||||
|
||||
try {
|
||||
const fileContent = await readFile(realPath, 'utf8');
|
||||
deepSet(partial, key.split('.'), fileContent.trim().normalize());
|
||||
} catch {
|
||||
throw ConfigError.from('MISSING_SECRET_FILE', {
|
||||
pathKey: `${key}_path`,
|
||||
filePath: realPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
const fileContent = await readFile(realPath, "utf8");
|
||||
deepSet(partial, key.split("."), fileContent.trim().normalize());
|
||||
} catch {
|
||||
throw ConfigError.from("MISSING_SECRET_FILE", {
|
||||
pathKey: `${key}_path`,
|
||||
filePath: realPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -254,14 +249,14 @@ export async function loadConfigKeyPaths(partial: PartialHeadplaneConfig) {
|
||||
* @returns The value at the specified path or undefined if not found
|
||||
*/
|
||||
function deepGet(obj: { [key: string]: unknown }, path: string[]): unknown {
|
||||
let current = obj;
|
||||
for (const segment of path) {
|
||||
if (current == null || typeof current !== 'object') {
|
||||
return undefined;
|
||||
}
|
||||
let current = obj;
|
||||
for (const segment of path) {
|
||||
if (current == null || typeof current !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
current = current[segment] as { [key: string]: unknown };
|
||||
}
|
||||
current = current[segment] as { [key: string]: unknown };
|
||||
}
|
||||
|
||||
return current;
|
||||
return current;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import type { Traversal } from 'arktype';
|
||||
import log from '~/utils/log';
|
||||
import type { Traversal } from "arktype";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
export function deprecatedField() {
|
||||
return (_: unknown, ctx: Traversal) => {
|
||||
log.warn('config', `${ctx.propString} is deprecated and has no effect.`);
|
||||
return true;
|
||||
};
|
||||
return (_: unknown, ctx: Traversal) => {
|
||||
log.warn("config", `${ctx.propString} is deprecated and has no effect.`);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import { join } from "node:path";
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { HeadplaneConfig } from "./config/config-schema";
|
||||
import { loadIntegration } from "./config/integration";
|
||||
import { createDbClient } from "./db/client.server";
|
||||
import { disabled, enabled, type Feature } from "./feature";
|
||||
import { createHeadscale, type HeadscaleClient } from "./headscale/api";
|
||||
import { loadHeadscaleConfig } from "./headscale/config-loader";
|
||||
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
|
||||
import { type AgentManager, createAgentManager } from "./hp-agent";
|
||||
import { createOidcService, type OidcService } from "./oidc/provider";
|
||||
import { createAuthService, type Principal } from "./web/auth";
|
||||
|
||||
export type AppContext = Awaited<ReturnType<typeof createAppContext>>;
|
||||
|
||||
declare module "react-router" {
|
||||
interface AppLoadContext extends AppContext {}
|
||||
}
|
||||
|
||||
export async function createAppContext(config: HeadplaneConfig) {
|
||||
const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));
|
||||
const headscale = await createHeadscale({
|
||||
url: config.headscale.url,
|
||||
certPath: config.headscale.tls_cert_path,
|
||||
});
|
||||
|
||||
// Resolve the Headscale API key: headscale.api_key takes precedence,
|
||||
// falling back to the deprecated oidc.headscale_api_key for compatibility.
|
||||
const headscaleApiKey = config.headscale.api_key ?? config.oidc?.headscale_api_key;
|
||||
|
||||
const agents = await buildAgents(
|
||||
config,
|
||||
headscale.capabilities.preAuthKeysHaveStableIds,
|
||||
headscaleApiKey ? headscale.client(headscaleApiKey) : undefined,
|
||||
db,
|
||||
);
|
||||
|
||||
const auth = createAuthService({
|
||||
secret: config.server.cookie_secret,
|
||||
headscaleApiKey,
|
||||
db,
|
||||
cookie: {
|
||||
name: "_hp_auth",
|
||||
secure: config.server.cookie_secure,
|
||||
maxAge: config.server.cookie_max_age,
|
||||
domain: config.server.cookie_domain,
|
||||
},
|
||||
});
|
||||
|
||||
const oidc = buildOidc(config, headscaleApiKey);
|
||||
|
||||
const hsLive = createLiveStore([nodesResource, usersResource]);
|
||||
const hs = await loadHeadscaleConfig(
|
||||
config.headscale.config_path,
|
||||
config.headscale.config_strict,
|
||||
config.headscale.dns_records_path,
|
||||
);
|
||||
const integration = await loadIntegration(config.integration);
|
||||
|
||||
// Disposers run in reverse-registration order on shutdown.
|
||||
const disposers: Array<() => Promise<void> | void> = [
|
||||
() => auth.stop(),
|
||||
() => hsLive.dispose(),
|
||||
() => headscale.dispose(),
|
||||
];
|
||||
if (agents.state === "enabled") {
|
||||
disposers.push(() => agents.value.dispose());
|
||||
}
|
||||
|
||||
async function apiForRequest(
|
||||
request: Request,
|
||||
): Promise<{ principal: Principal; api: HeadscaleClient }> {
|
||||
const principal = await auth.require(request);
|
||||
const apiKey = auth.getHeadscaleApiKey(principal);
|
||||
return { principal, api: headscale.client(apiKey) };
|
||||
}
|
||||
|
||||
function startServices() {
|
||||
auth.start();
|
||||
}
|
||||
|
||||
async function dispose() {
|
||||
for (const d of [...disposers].reverse()) {
|
||||
try {
|
||||
await d();
|
||||
} catch (error) {
|
||||
log.warn("server", "Error during shutdown: %s", String(error));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
db,
|
||||
headscale,
|
||||
headscaleApiKey,
|
||||
agents,
|
||||
auth,
|
||||
oidc,
|
||||
hsLive,
|
||||
hs,
|
||||
integration,
|
||||
apiForRequest,
|
||||
startServices,
|
||||
dispose,
|
||||
};
|
||||
}
|
||||
|
||||
function buildOidc(
|
||||
config: HeadplaneConfig,
|
||||
headscaleApiKey: string | undefined,
|
||||
): Feature<OidcService> {
|
||||
if (!config.oidc) {
|
||||
return disabled("OIDC is not configured");
|
||||
}
|
||||
if (config.oidc.enabled === false) {
|
||||
return disabled("OIDC is disabled in the configuration");
|
||||
}
|
||||
if (!headscaleApiKey) {
|
||||
return disabled("OIDC requires headscale.api_key to be configured");
|
||||
}
|
||||
|
||||
return enabled(
|
||||
createOidcService({
|
||||
issuer: config.oidc.issuer,
|
||||
clientId: config.oidc.client_id,
|
||||
clientSecret: config.oidc.client_secret,
|
||||
baseUrl: config.server.base_url ?? "",
|
||||
authorizationEndpoint: config.oidc.authorization_endpoint,
|
||||
tokenEndpoint: config.oidc.token_endpoint,
|
||||
userinfoEndpoint: config.oidc.userinfo_endpoint,
|
||||
endSessionEndpoint: config.oidc.end_session_endpoint,
|
||||
tokenEndpointAuthMethod:
|
||||
config.oidc.token_endpoint_auth_method === "client_secret_jwt"
|
||||
? undefined
|
||||
: config.oidc.token_endpoint_auth_method,
|
||||
usePkce: config.oidc.use_pkce,
|
||||
scope: config.oidc.scope,
|
||||
subjectClaims: config.oidc.subject_claims,
|
||||
allowWeakRsaKeys: config.oidc.allow_weak_rsa_keys,
|
||||
extraParams: config.oidc.extra_params,
|
||||
profilePictureSource: config.oidc.profile_picture_source,
|
||||
postLogoutRedirectUri: config.oidc.post_logout_redirect_uri,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
async function buildAgents(
|
||||
config: HeadplaneConfig,
|
||||
supportsTagOnlyKeys: boolean,
|
||||
apiClient: HeadscaleClient | undefined,
|
||||
db: Awaited<ReturnType<typeof createDbClient>>,
|
||||
): Promise<Feature<AgentManager>> {
|
||||
const agentConfig = config.integration?.agent;
|
||||
if (!agentConfig?.enabled) {
|
||||
return disabled("Agent is not enabled in the configuration");
|
||||
}
|
||||
if (!apiClient) {
|
||||
return disabled("Agent requires headscale.api_key to be configured");
|
||||
}
|
||||
if (!supportsTagOnlyKeys) {
|
||||
return disabled("Agent requires Headscale 0.28 or newer");
|
||||
}
|
||||
|
||||
const manager = await createAgentManager(
|
||||
agentConfig,
|
||||
config.headscale.url,
|
||||
apiClient,
|
||||
supportsTagOnlyKeys,
|
||||
db,
|
||||
);
|
||||
if (!manager) {
|
||||
return disabled("Agent failed to initialize (see logs)");
|
||||
}
|
||||
return enabled(manager);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { eq, isNotNull } from "drizzle-orm";
|
||||
|
||||
import { nodesResource } from "~/server/headscale/live-store";
|
||||
import log from "~/utils/log";
|
||||
|
||||
import type { Route } from "../../layout/+types/app";
|
||||
import { ephemeralNodes } from "./schema";
|
||||
|
||||
export async function pruneEphemeralNodes({ context, request }: Route.LoaderArgs) {
|
||||
const principal = await context.auth.require(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 apiKey = context.auth.getHeadscaleApiKey(principal, context.oidc?.apiKey);
|
||||
const api = context.hsApi.getRuntimeClient(apiKey);
|
||||
const nodes = await api.getNodes();
|
||||
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 api.deleteNode(node.id);
|
||||
|
||||
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()));
|
||||
|
||||
if (toPrune.length > 0) {
|
||||
await context.hsLive.refresh(nodesResource, api);
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,6 @@ 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>(),
|
||||
@@ -44,6 +36,7 @@ export const authSessions = sqliteTable("auth_sessions", {
|
||||
user_id: text("user_id"),
|
||||
api_key_hash: text("api_key_hash"),
|
||||
api_key_display: text("api_key_display"),
|
||||
oidc_id_token: text("oidc_id_token"),
|
||||
expires_at: integer("expires_at", { mode: "timestamp" }).notNull(),
|
||||
created_at: integer("created_at", { mode: "timestamp" }).$default(() => new Date()),
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// MARK: Feature<T>
|
||||
//
|
||||
// A two-state tagged union used in place of `T | undefined` for
|
||||
// optional features on the AppContext. Carries a human-readable
|
||||
// reason when the feature is disabled so loaders can surface it (or
|
||||
// choose to ignore it).
|
||||
//
|
||||
// This is deliberately *not* a Result/Either. It does not represent
|
||||
// async readiness or retryable failure — it represents "is this
|
||||
// feature wired up at all." Services that have their own runtime
|
||||
// status (e.g. OIDC discovery) keep that on the service itself.
|
||||
|
||||
export type Feature<T> = { state: "enabled"; value: T } | { state: "disabled"; reason: string };
|
||||
|
||||
export const enabled = <T>(value: T): Feature<T> => ({ state: "enabled", value });
|
||||
|
||||
export const disabled = (reason: string): Feature<never> => ({
|
||||
state: "disabled",
|
||||
reason,
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
// MARK: Headscale Capabilities
|
||||
//
|
||||
// Behavioural facts about the connected Headscale server, derived
|
||||
// once from `ServerVersion` at boot. Capabilities are named for *what
|
||||
// changed* — not for the version number it changed in — so a reader
|
||||
// who has never seen Headscale can tell what each flag controls
|
||||
// without consulting a release note.
|
||||
//
|
||||
// Add a capability here when you find yourself reaching for a raw
|
||||
// version comparison in endpoint or route code. Adding a capability
|
||||
// is also the right answer when a new Headscale release changes wire
|
||||
// format or removes an endpoint.
|
||||
|
||||
import { gte, type ServerVersion } from "./server-version";
|
||||
|
||||
export interface Capabilities {
|
||||
/**
|
||||
* Pre-auth keys have stable IDs. `GET /api/v1/preauthkey` (no
|
||||
* user filter) returns every key in the system, and
|
||||
* `POST /api/v1/preauthkey/expire` takes `{ id }` instead of
|
||||
* `{ user, key }`. Tag-only pre-auth keys (no owning user) are
|
||||
* supported. Introduced in 0.28.0.
|
||||
*/
|
||||
readonly preAuthKeysHaveStableIds: boolean;
|
||||
|
||||
/**
|
||||
* Node tags are a flat `tags: string[]` field on the wire.
|
||||
* Pre-0.28 returned `forcedTags` / `validTags` / `invalidTags`
|
||||
* that the client had to union itself. Introduced in 0.28.0.
|
||||
*/
|
||||
readonly nodeTagsAreFlat: boolean;
|
||||
|
||||
/**
|
||||
* A node's owning user is immutable after creation;
|
||||
* `POST /api/v1/node/{id}/user` no longer reassigns. Effective in
|
||||
* 0.28.0+.
|
||||
*/
|
||||
readonly nodeOwnerIsImmutable: boolean;
|
||||
}
|
||||
|
||||
export function capabilitiesFor(version: ServerVersion): Capabilities {
|
||||
return {
|
||||
preAuthKeysHaveStableIds: gte(version, "0.28.0"),
|
||||
nodeTagsAreFlat: gte(version, "0.28.0"),
|
||||
nodeOwnerIsImmutable: gte(version, "0.28.0"),
|
||||
};
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type { Key } from '~/types';
|
||||
import { defineApiEndpoints } from '../factory';
|
||||
|
||||
export interface ApiKeyEndpoints {
|
||||
/**
|
||||
* Retrieves all API keys from the Headscale instance.
|
||||
*
|
||||
* @returns An array of `Key` objects representing the API keys.
|
||||
*/
|
||||
getApiKeys(): Promise<Key[]>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<ApiKeyEndpoints>((client, apiKey) => ({
|
||||
getApiKeys: async () => {
|
||||
const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>(
|
||||
'GET',
|
||||
'v1/apikey',
|
||||
apiKey,
|
||||
);
|
||||
|
||||
return apiKeys;
|
||||
},
|
||||
}));
|
||||
@@ -1,79 +0,0 @@
|
||||
import {
|
||||
composeEndpoints,
|
||||
defineApiEndpoints,
|
||||
type ExtractApiEndpoints,
|
||||
type UnionToIntersection,
|
||||
} from '../factory';
|
||||
import type { HeadscaleApiInterface } from '../index';
|
||||
import apiKeyEndpoints from './api-keys';
|
||||
import nodeEndpoints from './nodes';
|
||||
import policyEndpoints from './policy';
|
||||
import preAuthKeyEndpoints from './pre-auth-keys';
|
||||
import userEndpoints from './users';
|
||||
|
||||
interface HealthcheckEndpoint {
|
||||
/**
|
||||
* Checks if the Headscale instance is healthy.
|
||||
*
|
||||
* @returns A boolean indicating if the instance is healthy.
|
||||
*/
|
||||
isHealthy(): Promise<boolean>;
|
||||
}
|
||||
|
||||
const healthcheckEndpoint = defineApiEndpoints<HealthcheckEndpoint>(
|
||||
(client, apiKey) => ({
|
||||
isHealthy: async () => {
|
||||
try {
|
||||
const res = await client.rawFetch('/health', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
// This doesn't really matter
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
return res.statusCode === 200;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* A constant list of all endpoint groups.
|
||||
* Add new endpoint groups here.
|
||||
*/
|
||||
export const endpointSets = [
|
||||
apiKeyEndpoints,
|
||||
healthcheckEndpoint,
|
||||
nodeEndpoints,
|
||||
policyEndpoints,
|
||||
preAuthKeyEndpoints,
|
||||
userEndpoints,
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* All of the available API methods when interacting with Headscale's API.
|
||||
* We have wrapped each operation with nice methods and parameters to make it
|
||||
* easier to do integration testing by spinning up an actual Headscale instance
|
||||
* and calling these methods against it.
|
||||
*
|
||||
* We also have the benefit of supporting multiple Headscale versions by
|
||||
* passing in different internal implementations based on the OpenAPI spec.
|
||||
*/
|
||||
export type RuntimeApiClient = UnionToIntersection<
|
||||
ExtractApiEndpoints<(typeof endpointSets)[number]>
|
||||
>;
|
||||
|
||||
/**
|
||||
* Composes all endpoint groups into a single runtime API client.
|
||||
*
|
||||
* @param client - The client helpers for making API requests.
|
||||
* @param apiKey - The API key for authentication.
|
||||
* @returns A fully composed runtime API client.
|
||||
*/
|
||||
export default (
|
||||
client: HeadscaleApiInterface['clientHelpers'],
|
||||
apiKey: string,
|
||||
) => composeEndpoints(endpointSets, client, apiKey);
|
||||
@@ -1,168 +0,0 @@
|
||||
import type { Machine } from "~/types";
|
||||
|
||||
import type { HeadscaleApiInterface } from "..";
|
||||
|
||||
import { defineApiEndpoints } from "../factory";
|
||||
|
||||
interface RawMachine extends Omit<Machine, "tags"> {
|
||||
tags?: string[];
|
||||
forcedTags?: string[];
|
||||
validTags?: string[];
|
||||
invalidTags?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes the tags of a RawMachine based on the Headscale version.
|
||||
*
|
||||
* @param client The Headscale API client helper.
|
||||
* @param node The RawMachine object to normalize.
|
||||
* @returns A Machine object with normalized tags.
|
||||
*/
|
||||
function normalizeTags(client: HeadscaleApiInterface["clientHelpers"], node: RawMachine): Machine {
|
||||
if (client.isAtleast("0.28.0")) {
|
||||
return { ...node, tags: node.tags ?? [] } as Machine;
|
||||
}
|
||||
|
||||
const tags = Array.from(new Set([...(node.forcedTags ?? []), ...(node.validTags ?? [])]));
|
||||
|
||||
return { ...node, tags } as Machine;
|
||||
}
|
||||
|
||||
export interface NodeEndpoints {
|
||||
/**
|
||||
* Retrieves all nodes (machines) from the Headscale instance.
|
||||
*
|
||||
* @returns An array of `Machine` objects representing the nodes.
|
||||
*/
|
||||
getNodes(): Promise<Machine[]>;
|
||||
|
||||
/**
|
||||
* Retrieves a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to retrieve.
|
||||
* @returns A `Machine` object representing the node.
|
||||
*/
|
||||
getNode(id: string): Promise<Machine>;
|
||||
|
||||
/**
|
||||
* Deletes a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to delete.
|
||||
*/
|
||||
deleteNode(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Registers a new node (machine) with the given user and key.
|
||||
*
|
||||
* @param user The user to associate with the node.
|
||||
* @param key The registration key for the node.
|
||||
* @returns A `Machine` object representing the newly registered node.
|
||||
*/
|
||||
registerNode(user: string, key: string): Promise<Machine>;
|
||||
|
||||
/**
|
||||
* Approves routes for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param routes An array of routes to approve for the node.
|
||||
*/
|
||||
approveNodeRoutes(id: string, routes: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Expires a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to expire.
|
||||
*/
|
||||
expireNode(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Renames a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node to rename.
|
||||
* @param newName The new name for the node.
|
||||
*/
|
||||
renameNode(id: string, newName: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets tags for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param tags An array of tags to set for the node.
|
||||
*/
|
||||
setNodeTags(id: string, tags: string[]): Promise<void>;
|
||||
|
||||
/**
|
||||
* Sets the user for a specific node (machine) by its ID.
|
||||
*
|
||||
* @param id The ID of the node.
|
||||
* @param user The user to set for the node.
|
||||
*/
|
||||
setNodeUser(id: string, user: string): Promise<void>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
|
||||
getNodes: async () => {
|
||||
const { nodes } = await client.apiFetch<{ nodes: RawMachine[] }>("GET", "v1/node", apiKey);
|
||||
return nodes.map((node) => normalizeTags(client, node));
|
||||
},
|
||||
|
||||
getNode: async (nodeId) => {
|
||||
const { node } = await client.apiFetch<{ node: RawMachine }>(
|
||||
"GET",
|
||||
`v1/node/${nodeId}`,
|
||||
apiKey,
|
||||
);
|
||||
|
||||
return normalizeTags(client, node);
|
||||
},
|
||||
|
||||
deleteNode: async (nodeId) => {
|
||||
await client.apiFetch<void>("DELETE", `v1/node/${nodeId}`, apiKey);
|
||||
},
|
||||
|
||||
registerNode: async (user, key) => {
|
||||
const qp = new URLSearchParams();
|
||||
qp.append("user", user);
|
||||
qp.append("key", key);
|
||||
const { node } = await client.apiFetch<{ node: RawMachine }>(
|
||||
"POST",
|
||||
`v1/node/register?${qp.toString()}`,
|
||||
apiKey,
|
||||
{
|
||||
user,
|
||||
key,
|
||||
},
|
||||
);
|
||||
|
||||
return normalizeTags(client, node);
|
||||
},
|
||||
|
||||
approveNodeRoutes: async (nodeId, routes) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/approve_routes`, apiKey, { routes });
|
||||
},
|
||||
|
||||
expireNode: async (nodeId) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/expire`, apiKey);
|
||||
},
|
||||
|
||||
renameNode: async (nodeId, newName) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey);
|
||||
},
|
||||
|
||||
setNodeTags: async (nodeId, tags) => {
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/tags`, apiKey, {
|
||||
tags,
|
||||
});
|
||||
},
|
||||
|
||||
setNodeUser: async (nodeId, user) => {
|
||||
// Headscale 0.28.0 got rid of node reassignment to users
|
||||
if (client.isAtleast("0.28.0")) {
|
||||
return;
|
||||
}
|
||||
|
||||
await client.apiFetch<void>("POST", `v1/node/${nodeId}/user`, apiKey, {
|
||||
user,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -1,41 +0,0 @@
|
||||
import { defineApiEndpoints } from '../factory';
|
||||
|
||||
export interface PolicyEndpoints {
|
||||
/**
|
||||
* Retrieves the current ACL policy from the Headscale instance.
|
||||
*
|
||||
* @returns The ACL policy as a string and the date it was last updated.
|
||||
*/
|
||||
getPolicy(): Promise<{ policy: string; updatedAt: Date | null }>;
|
||||
|
||||
/**
|
||||
* Sets the ACL policy for the Headscale instance.
|
||||
*
|
||||
* @param policy The ACL policy as a string.
|
||||
* @returns The updated ACL policy as a string and the date it was last updated.
|
||||
*/
|
||||
setPolicy(policy: string): Promise<{ policy: string; updatedAt: Date }>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<PolicyEndpoints>((client, apiKey) => ({
|
||||
getPolicy: async () => {
|
||||
const { policy, updatedAt } = await client.apiFetch<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>('GET', 'v1/policy', apiKey);
|
||||
|
||||
return {
|
||||
policy,
|
||||
updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
|
||||
};
|
||||
},
|
||||
|
||||
setPolicy: async (policy) => {
|
||||
const { policy: newPolicy, updatedAt } = await client.apiFetch<{
|
||||
policy: string;
|
||||
updatedAt: string;
|
||||
}>('PUT', 'v1/policy', apiKey, { policy });
|
||||
|
||||
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
|
||||
},
|
||||
}));
|
||||
@@ -1,85 +0,0 @@
|
||||
import type { PreAuthKey } from "~/types";
|
||||
|
||||
import { defineApiEndpoints } from "../factory";
|
||||
|
||||
export interface PreAuthKeyEndpoints {
|
||||
/**
|
||||
* List all pre-auth keys. Requires Headscale 0.28+.
|
||||
*/
|
||||
getAllPreAuthKeys(): Promise<PreAuthKey[]>;
|
||||
|
||||
/**
|
||||
* Retrieves all pre-authentication keys for a specific user.
|
||||
*
|
||||
* @param user The user to retrieve pre-authentication keys for.
|
||||
* @returns An array of `PreAuthKey` objects representing the pre-authentication keys.
|
||||
*/
|
||||
getPreAuthKeys(user: string): Promise<PreAuthKey[]>;
|
||||
|
||||
/**
|
||||
* Creates a new pre-authentication key.
|
||||
* User can be null for tag-only keys (requires Headscale 0.28+).
|
||||
*/
|
||||
createPreAuthKey(
|
||||
user: string | null,
|
||||
ephemeral: boolean,
|
||||
reusable: boolean,
|
||||
expiration: Date | null,
|
||||
aclTags: string[] | null,
|
||||
): Promise<PreAuthKey>;
|
||||
|
||||
/**
|
||||
* Expires a specific pre-authentication key for a user.
|
||||
*
|
||||
* @param user The user associated with the pre-authentication key.
|
||||
* @param key The pre-authentication key to expire.
|
||||
*/
|
||||
expirePreAuthKey(user: string, key: string): Promise<void>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
|
||||
getAllPreAuthKeys: async () => {
|
||||
const { preAuthKeys } = await client.apiFetch<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>("GET", "v1/preauthkey", apiKey, {});
|
||||
|
||||
return preAuthKeys;
|
||||
},
|
||||
|
||||
getPreAuthKeys: async (user) => {
|
||||
const { preAuthKeys } = await client.apiFetch<{
|
||||
preAuthKeys: PreAuthKey[];
|
||||
}>("GET", "v1/preauthkey", apiKey, { user });
|
||||
|
||||
return preAuthKeys;
|
||||
},
|
||||
|
||||
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
|
||||
const body: Record<string, unknown> = {
|
||||
ephemeral,
|
||||
reusable,
|
||||
expiration: expiration ? expiration.toISOString() : null,
|
||||
};
|
||||
|
||||
if (user) {
|
||||
body.user = user;
|
||||
}
|
||||
|
||||
if (aclTags && aclTags.length > 0) {
|
||||
body.aclTags = aclTags;
|
||||
}
|
||||
|
||||
const { preAuthKey } = await client.apiFetch<{
|
||||
preAuthKey: PreAuthKey;
|
||||
}>("POST", "v1/preauthkey", apiKey, body);
|
||||
|
||||
return preAuthKey;
|
||||
},
|
||||
|
||||
expirePreAuthKey: async (user, key) => {
|
||||
await client.apiFetch<void>("POST", "v1/preauthkey/expire", apiKey, {
|
||||
user,
|
||||
key,
|
||||
});
|
||||
},
|
||||
}));
|
||||
@@ -1,88 +0,0 @@
|
||||
import type { User } from '~/types';
|
||||
import { defineApiEndpoints } from '../factory';
|
||||
|
||||
export interface UserEndpoints {
|
||||
/**
|
||||
* Retrieves users from the Headscale instance, optionally filtering by ID, name, or email.
|
||||
*
|
||||
* @param id Optional ID of the user to retrieve.
|
||||
* @param name Optional name of the user to retrieve.
|
||||
* @param email Optional email of the user to retrieve.
|
||||
* @returns An array of `User` objects representing the users.
|
||||
*/
|
||||
getUsers(id?: string, name?: string, email?: string): Promise<User[]>;
|
||||
|
||||
/**
|
||||
* Creates a new user in the Headscale instance.
|
||||
*
|
||||
* @param username The username of the new user.
|
||||
* @param email Optional email of the new user.
|
||||
* @param displayName Optional display name of the new user.
|
||||
* @param pictureUrl Optional picture URL of the new user.
|
||||
* @returns A `User` object representing the newly created user.
|
||||
*/
|
||||
createUser(
|
||||
username: string,
|
||||
email?: string,
|
||||
displayName?: string,
|
||||
pictureUrl?: string,
|
||||
): Promise<User>;
|
||||
|
||||
/**
|
||||
* Deletes a specific user by its ID.
|
||||
*
|
||||
* @param id The ID of the user to delete.
|
||||
*/
|
||||
deleteUser(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Renames a specific user by its ID.
|
||||
*
|
||||
* @param id The ID of the user to rename.
|
||||
* @param newName The new name for the user.
|
||||
*/
|
||||
renameUser(id: string, newName: string): Promise<void>;
|
||||
}
|
||||
|
||||
export default defineApiEndpoints<UserEndpoints>((client, apiKey) => ({
|
||||
getUsers: async (id, name, email) => {
|
||||
const moreThanOneFilter =
|
||||
[id, name, email].filter((v) => v !== undefined).length > 1;
|
||||
|
||||
if (moreThanOneFilter) {
|
||||
throw new Error('Only one of id, name, or email filters can be provided');
|
||||
}
|
||||
|
||||
const { users } = await client.apiFetch<{ users: User[] }>(
|
||||
'GET',
|
||||
'v1/user',
|
||||
apiKey,
|
||||
{ id, name, email },
|
||||
);
|
||||
|
||||
return users;
|
||||
},
|
||||
|
||||
createUser: async (username, email, displayName, pictureUrl) => {
|
||||
const { user } = await client.apiFetch<{ user: User }>(
|
||||
'POST',
|
||||
'v1/user',
|
||||
apiKey,
|
||||
{ name: username, email, displayName, pictureUrl },
|
||||
);
|
||||
|
||||
return user;
|
||||
},
|
||||
|
||||
deleteUser: async (id) => {
|
||||
await client.apiFetch<void>('DELETE', `v1/user/${id}`, apiKey);
|
||||
},
|
||||
|
||||
renameUser: async (oldId, newName) => {
|
||||
await client.apiFetch<void>(
|
||||
'POST',
|
||||
`v1/user/${oldId}/rename/${newName}`,
|
||||
apiKey,
|
||||
);
|
||||
},
|
||||
}));
|
||||
@@ -1,13 +1,13 @@
|
||||
import type { HeadscaleConnectionError } from './error';
|
||||
import type { HeadscaleConnectionError } from "./error";
|
||||
|
||||
/**
|
||||
* Represents an error returned by the Headscale API.
|
||||
*/
|
||||
export interface HeadscaleAPIError {
|
||||
requestUrl: `${string} ${string}`;
|
||||
statusCode: number;
|
||||
rawData: string;
|
||||
data: Record<string, unknown> | null;
|
||||
requestUrl: `${string} ${string}`;
|
||||
statusCode: number;
|
||||
rawData: string;
|
||||
data: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -16,14 +16,14 @@ export interface HeadscaleAPIError {
|
||||
* @returns True if the error is a HeadscaleAPIError, false otherwise.
|
||||
*/
|
||||
export function isApiError(error: unknown): error is HeadscaleAPIError {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'requestUrl' in error &&
|
||||
'statusCode' in error &&
|
||||
'rawData' in error &&
|
||||
'data' in error
|
||||
);
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === "object" &&
|
||||
"requestUrl" in error &&
|
||||
"statusCode" in error &&
|
||||
"rawData" in error &&
|
||||
"data" in error
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -31,17 +31,15 @@ export function isApiError(error: unknown): error is HeadscaleAPIError {
|
||||
* @param error - The error to check.
|
||||
* @returns True if the error is a HeadscaleConnectionError, false otherwise.
|
||||
*/
|
||||
export function isConnectionError(
|
||||
error: unknown,
|
||||
): error is HeadscaleConnectionError {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'requestUrl' in error &&
|
||||
'errorCode' in error &&
|
||||
'errorMessage' in error &&
|
||||
'extraData' in error
|
||||
);
|
||||
export function isConnectionError(error: unknown): error is HeadscaleConnectionError {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === "object" &&
|
||||
"requestUrl" in error &&
|
||||
"errorCode" in error &&
|
||||
"errorMessage" in error &&
|
||||
"extraData" in error
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -52,15 +50,15 @@ export function isConnectionError(
|
||||
* @returns True if the error is a DataUnauthorizedError, false otherwise.
|
||||
*/
|
||||
export function isDataUnauthorizedError(error: unknown): boolean {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'data' in error &&
|
||||
typeof error.data === 'object' &&
|
||||
error.data != null &&
|
||||
'statusCode' in error.data &&
|
||||
error.data.statusCode === 401
|
||||
);
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === "object" &&
|
||||
"data" in error &&
|
||||
typeof error.data === "object" &&
|
||||
error.data != null &&
|
||||
"statusCode" in error.data &&
|
||||
error.data.statusCode === 401
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,13 +70,6 @@ export function isDataUnauthorizedError(error: unknown): boolean {
|
||||
* @returns True if the error is a DataWithResponseInit containing a
|
||||
* HeadscaleAPIError, false otherwise.
|
||||
*/
|
||||
export function isDataWithApiError(
|
||||
error: unknown,
|
||||
): error is { data: HeadscaleAPIError } {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'data' in error &&
|
||||
isApiError(error.data)
|
||||
);
|
||||
export function isDataWithApiError(error: unknown): error is { data: HeadscaleAPIError } {
|
||||
return error != null && typeof error === "object" && "data" in error && isApiError(error.data);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { errors } from 'undici';
|
||||
import { errors } from "undici";
|
||||
|
||||
/**
|
||||
* Helper function that determines if an error is a Node.js exception
|
||||
@@ -6,22 +6,17 @@ import { errors } from 'undici';
|
||||
* @returns True if the error is a Node.js exception, false otherwise
|
||||
*/
|
||||
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
|
||||
return (
|
||||
error != null &&
|
||||
typeof error === 'object' &&
|
||||
'code' in error &&
|
||||
'errno' in error
|
||||
);
|
||||
return error != null && typeof error === "object" && "code" in error && "errno" in error;
|
||||
}
|
||||
|
||||
/**
|
||||
* A friendly error representation for Headscale connection issues.
|
||||
*/
|
||||
export interface HeadscaleConnectionError {
|
||||
requestUrl: string;
|
||||
errorCode: string;
|
||||
errorMessage: string;
|
||||
extraData: Record<string, unknown> | null;
|
||||
requestUrl: string;
|
||||
errorCode: string;
|
||||
errorMessage: string;
|
||||
extraData: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -33,40 +28,40 @@ export interface HeadscaleConnectionError {
|
||||
* @returns A friendly HeadscaleAPIError.
|
||||
*/
|
||||
export function undiciToFriendlyError(
|
||||
error: unknown,
|
||||
requestUrl: string,
|
||||
error: unknown,
|
||||
requestUrl: string,
|
||||
): HeadscaleConnectionError {
|
||||
// MARK: Do we need to go deeper into causes here?
|
||||
if (error instanceof AggregateError) {
|
||||
error = error.errors[0];
|
||||
}
|
||||
// MARK: Do we need to go deeper into causes here?
|
||||
if (error instanceof AggregateError) {
|
||||
error = error.errors[0];
|
||||
}
|
||||
|
||||
if (error instanceof errors.UndiciError) {
|
||||
return {
|
||||
requestUrl,
|
||||
errorCode: error.code,
|
||||
errorMessage: error.message,
|
||||
extraData: null,
|
||||
};
|
||||
}
|
||||
if (error instanceof errors.UndiciError) {
|
||||
return {
|
||||
requestUrl,
|
||||
errorCode: error.code,
|
||||
errorMessage: error.message,
|
||||
extraData: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (isNodeNetworkError(error)) {
|
||||
return {
|
||||
requestUrl,
|
||||
errorCode: error.code ?? 'UNKNOWN_NODE_NETWORK_ERROR',
|
||||
errorMessage: error.message,
|
||||
extraData: {
|
||||
syscall: error.syscall,
|
||||
path: error.path,
|
||||
errno: error.errno,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (isNodeNetworkError(error)) {
|
||||
return {
|
||||
requestUrl,
|
||||
errorCode: error.code ?? "UNKNOWN_NODE_NETWORK_ERROR",
|
||||
errorMessage: error.message,
|
||||
extraData: {
|
||||
syscall: error.syscall,
|
||||
path: error.path,
|
||||
errno: error.errno,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
requestUrl,
|
||||
errorCode: 'UNKNOWN_ERROR',
|
||||
errorMessage: 'An unknown error occured',
|
||||
extraData: null,
|
||||
};
|
||||
return {
|
||||
requestUrl,
|
||||
errorCode: "UNKNOWN_ERROR",
|
||||
errorMessage: "An unknown error occured",
|
||||
extraData: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { HeadscaleApiInterface } from '../api';
|
||||
|
||||
/**
|
||||
* Creates a strongly-typed group factory for a given endpoint interface.
|
||||
*
|
||||
* Example:
|
||||
* export const apiKeyGroup = defineGroup<ApiKeyEndpoints>({...})
|
||||
*/
|
||||
|
||||
export interface EndpointFactory<T extends object> {
|
||||
__type?: T;
|
||||
(client: HeadscaleApiInterface['clientHelpers'], apiKey: string): T;
|
||||
}
|
||||
|
||||
export function defineApiEndpoints<T extends object>(
|
||||
factories: (
|
||||
client: HeadscaleApiInterface['clientHelpers'],
|
||||
apiKey: string,
|
||||
) => T,
|
||||
): EndpointFactory<T> {
|
||||
return factories;
|
||||
}
|
||||
|
||||
export type ExtractApiEndpoints<F> = F extends EndpointFactory<infer T>
|
||||
? T
|
||||
: never;
|
||||
export type UnionToIntersection<U> = (
|
||||
U extends any
|
||||
? (k: U) => void
|
||||
: never
|
||||
) extends (k: infer I) => void
|
||||
? I
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Compose multiple endpoint sets into a single typed runtime client
|
||||
*/
|
||||
export function composeEndpoints<T extends readonly EndpointFactory<any>[]>(
|
||||
factories: T,
|
||||
clientHelpers: any,
|
||||
apiKey: string,
|
||||
): UnionToIntersection<ExtractApiEndpoints<T[number]>> {
|
||||
const instances = factories.map((f) => f(clientHelpers, apiKey));
|
||||
return Object.assign({}, ...instances) as any;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { dereference } from '@readme/openapi-parser';
|
||||
import { OpenAPIV2 } from 'openapi-types';
|
||||
|
||||
/**
|
||||
* A map of operation IDs to their hashes.
|
||||
*/
|
||||
export interface DocumentHash {
|
||||
[operationId: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an OpenAPI v2 document, generate a map of operations with hashes.
|
||||
* This gives us deterministic identifers to determine the version of Headscale
|
||||
* that is being used at runtime.
|
||||
*
|
||||
* @param doc The OpenAPI v2 document to hash.
|
||||
* @returns A map of operation IDs to their hashes.
|
||||
*/
|
||||
export async function hashOpenApiDocument(
|
||||
doc: OpenAPIV2.Document,
|
||||
): Promise<DocumentHash> {
|
||||
const spec = await dereference(doc);
|
||||
const hashes: DocumentHash = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [path, item] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
if (typeof operation !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
|
||||
const raw = JSON.stringify(
|
||||
{
|
||||
path,
|
||||
method: method.toUpperCase(),
|
||||
parameters,
|
||||
responses,
|
||||
},
|
||||
Object.keys({ path, method, parameters, responses }).sort(),
|
||||
);
|
||||
|
||||
const hash = createHash('md5').update(raw).digest('hex').slice(0, 16);
|
||||
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
|
||||
seen.add(final);
|
||||
hashes[`${method.toUpperCase()} ${path}`] = final;
|
||||
}
|
||||
}
|
||||
|
||||
return hashes;
|
||||
}
|
||||
+141
-306
@@ -1,328 +1,163 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { dereference } from "@readme/openapi-parser";
|
||||
import type { OpenAPIV2 } from "openapi-types";
|
||||
import { data } from "react-router";
|
||||
import { Agent, type Dispatcher, request } from "undici";
|
||||
// MARK: Headscale API
|
||||
//
|
||||
// The public entry point for talking to a Headscale server. At boot
|
||||
// we try `GET /version` (unauthenticated, present since Headscale
|
||||
// 0.27.0 — the minimum version Headplane supports) to derive a
|
||||
// typed `Capabilities` object. Boot outcomes:
|
||||
//
|
||||
// - success: parse the response, derive capabilities, done.
|
||||
// - 404: Headscale is reachable but predates 0.27.0 and is no
|
||||
// longer supported. Log an error and keep retrying so an
|
||||
// upgrade is picked up without a Headplane restart.
|
||||
// - any other failure (network, 5xx, parse): Headplane still
|
||||
// boots with `version = unknown` (capabilities-permissive) and
|
||||
// a background retry. This handles docker-compose start-order
|
||||
// races without making the whole process unhappy.
|
||||
//
|
||||
// Capabilities are always derived from `version`; once detection
|
||||
// finishes there's no further state to track.
|
||||
|
||||
import log from "~/utils/log";
|
||||
|
||||
import endpointSets, { RuntimeApiClient } from "./endpoints";
|
||||
import { undiciToFriendlyError } from "./error";
|
||||
import { HeadscaleAPIError, isApiError } from "./error-client";
|
||||
import { detectApiVersion, isAtLeast, type Version } from "./version";
|
||||
import { type Capabilities, capabilitiesFor } from "./capabilities";
|
||||
import { isDataWithApiError } from "./error-client";
|
||||
import { type ApiKeyApi, makeApiKeyApi } from "./resources/api-keys";
|
||||
import { makeNodeApi, type NodeApi } from "./resources/nodes";
|
||||
import { makePolicyApi, type PolicyApi } from "./resources/policy";
|
||||
import { makePreAuthKeyApi, type PreAuthKeyApi } from "./resources/pre-auth-keys";
|
||||
import { makeUserApi, type UserApi } from "./resources/users";
|
||||
import { formatServerVersion, parseServerVersion, type ServerVersion } from "./server-version";
|
||||
import { createTransport } from "./transport";
|
||||
|
||||
/**
|
||||
* A low-level composed interface for interacting with the Headscale API.
|
||||
* This interface provides direct access to the underlying Undici agent
|
||||
* and methods for making API requests.
|
||||
*
|
||||
* It is also responsible for handling OpenAPI spec polling and hashing to
|
||||
* determine the implementations of API methods when requested for use.
|
||||
*/
|
||||
export interface HeadscaleApiInterface {
|
||||
/**
|
||||
* The underlying Undici agent used for making requests.
|
||||
*/
|
||||
undiciAgent: Agent;
|
||||
const MIN_SUPPORTED_VERSION = "0.27.0";
|
||||
|
||||
/**
|
||||
* The base URL of the Headscale API.
|
||||
*/
|
||||
baseUrl: string;
|
||||
|
||||
/**
|
||||
* The OpenAPI hashes retrieved from the Headscale instance at runtime.
|
||||
* This is used to determine which implementations of API methods to use.
|
||||
*/
|
||||
openapiHashes: Record<string, string> | null;
|
||||
|
||||
/**
|
||||
* The detected API version of the connected Headscale instance.
|
||||
*/
|
||||
apiVersion: Version;
|
||||
|
||||
/**
|
||||
* Retrieves a runtime API client for the given API key.
|
||||
*
|
||||
* @param apiKey The API key to use for authentication.
|
||||
* @returns A `RuntimeApiClient` instance for interacting with the API.
|
||||
*/
|
||||
getRuntimeClient(apiKey: string): RuntimeApiClient;
|
||||
|
||||
/**
|
||||
* A set of helper methods made available to API method implementations.
|
||||
* The idea is to make interacting with the API easier by providing
|
||||
* common functionality that can be reused across multiple methods.
|
||||
*/
|
||||
clientHelpers: {
|
||||
/**
|
||||
* Checks if the connected Headscale instance's API version
|
||||
* is at least the specified version.
|
||||
*
|
||||
* @param version The version to check against.
|
||||
* @returns `true` if the API version is at least the specified version, `false` otherwise.
|
||||
*/
|
||||
isAtleast(version: Version): boolean;
|
||||
|
||||
/**
|
||||
* Makes a raw fetch request to the Headscale API via the Undici agent.
|
||||
* This method is used internally by API method implementations
|
||||
* to make requests to the Headscale API.
|
||||
*
|
||||
* @param path The API path to request.
|
||||
* @param options Optional request options.
|
||||
* @returns A promise that resolves to the response data.
|
||||
*/
|
||||
rawFetch(
|
||||
path: string,
|
||||
options?: Partial<Dispatcher.RequestOptions>,
|
||||
): Promise<Dispatcher.ResponseData>;
|
||||
|
||||
/**
|
||||
* Makes a typed API fetch request to the Headscale API.
|
||||
* This method is used internally by API method implementations
|
||||
* to make requests to the Headscale API and parse the response.
|
||||
*
|
||||
* @param method The HTTP method to use.
|
||||
* @param apiPath The API path to request.
|
||||
* @param apiKey The API key to use for authentication.
|
||||
* @param bodyOrQuery Optional body or query parameters.
|
||||
* @returns A promise that resolves to the typed response data.
|
||||
*/
|
||||
apiFetch<T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
apiPath: `v1/${string}`,
|
||||
apiKey: string,
|
||||
bodyOrQuery?: Record<string, unknown>,
|
||||
): Promise<T>;
|
||||
};
|
||||
export interface Headscale {
|
||||
readonly version: ServerVersion;
|
||||
readonly capabilities: Capabilities;
|
||||
/** True if the Headscale server's `/health` endpoint returns 200. */
|
||||
health(): Promise<boolean>;
|
||||
/** Build an API client bound to a specific Headscale API key. */
|
||||
client(apiKey: string): HeadscaleClient;
|
||||
/** Stop background work and close the underlying HTTP agent. */
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Headscale API client interface.
|
||||
*
|
||||
* @param baseUrl The base URL of the Headscale API.
|
||||
* @param certPath Optional path to a custom TLS certificate for secure connections.
|
||||
* @returns A promise that resolves to a `HeadscaleApiClient` instance.
|
||||
*/
|
||||
export async function createHeadscaleInterface(
|
||||
baseUrl: string,
|
||||
certPath?: string,
|
||||
): Promise<HeadscaleApiInterface> {
|
||||
const undiciAgent = await createUndiciAgent(certPath);
|
||||
let openapiHashes: Record<string, string> | null = null;
|
||||
let apiVersion: Version;
|
||||
|
||||
const rawFetch = async (
|
||||
url: string,
|
||||
options?: Partial<Dispatcher.RequestOptions>,
|
||||
): Promise<Dispatcher.ResponseData> => {
|
||||
const method = options?.method ?? "GET";
|
||||
log.debug("api", "%s %s", method, url);
|
||||
|
||||
try {
|
||||
const res = await request(new URL(url, baseUrl), {
|
||||
dispatcher: undiciAgent,
|
||||
headers: {
|
||||
...options?.headers,
|
||||
Accept: "application/json",
|
||||
"User-Agent": `Headplane/${__VERSION__}`,
|
||||
},
|
||||
|
||||
body: options?.body,
|
||||
method,
|
||||
});
|
||||
|
||||
return res;
|
||||
} catch (error) {
|
||||
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
|
||||
throw data(errorBody, {
|
||||
status: 502,
|
||||
statusText: "Bad Gateway",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const apiFetch = async <T>(
|
||||
method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH",
|
||||
apiPath: `v1/${string}`,
|
||||
apiKey: string,
|
||||
bodyOrQuery?: Record<string, unknown>,
|
||||
): Promise<T> => {
|
||||
let url = `/api/${apiPath}`;
|
||||
const options: Partial<Dispatcher.RequestOptions> = {
|
||||
method: method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
};
|
||||
|
||||
if (bodyOrQuery) {
|
||||
if (method === "GET" || method === "DELETE") {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(bodyOrQuery)) {
|
||||
if (value !== undefined) {
|
||||
params.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
if ([...params.keys()].length > 0) {
|
||||
url += `?${params.toString()}`;
|
||||
}
|
||||
} else {
|
||||
options.body = JSON.stringify(bodyOrQuery);
|
||||
options.headers = {
|
||||
...options.headers,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const res = await rawFetch(url, options);
|
||||
if (res.statusCode >= 400) {
|
||||
log.debug("api", "%s %s failed with status %d", method, apiPath, res.statusCode);
|
||||
const rawData = await res.body.text();
|
||||
const jsonData = (() => {
|
||||
try {
|
||||
return JSON.parse(rawData) as Record<string, unknown>;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
throw data(
|
||||
{
|
||||
requestUrl: `${method} ${apiPath}`,
|
||||
statusCode: res.statusCode,
|
||||
rawData,
|
||||
data: jsonData,
|
||||
} satisfies HeadscaleAPIError,
|
||||
{ status: 502, statusText: "Bad Gateway" },
|
||||
);
|
||||
}
|
||||
|
||||
return res.body.json() as Promise<T>;
|
||||
};
|
||||
export interface HeadscaleClient {
|
||||
nodes: NodeApi;
|
||||
users: UserApi;
|
||||
policy: PolicyApi;
|
||||
preAuthKeys: PreAuthKeyApi;
|
||||
apiKeys: ApiKeyApi;
|
||||
}
|
||||
|
||||
export interface CreateHeadscaleOptions {
|
||||
url: string;
|
||||
certPath?: string;
|
||||
/**
|
||||
* Polls the OpenAPI spec endpoint and generates operation hashes.
|
||||
* This is used to determine which implementations of API methods to use.
|
||||
*
|
||||
* @returns A promise that resolves to the OpenAPI operation hashes.
|
||||
* How often to retry `/version` while Headscale is unreachable.
|
||||
* Defaults to 30 seconds. Exposed for tests.
|
||||
*/
|
||||
async function fetchAndHashOpenapi(): Promise<Record<string, string> | null> {
|
||||
try {
|
||||
const res = await rawFetch("/swagger/v1/openapiv2.json");
|
||||
if (res.statusCode !== 200) {
|
||||
log.error("api", "Failed to fetch OpenAPI spec: %d", res.statusCode);
|
||||
return null;
|
||||
}
|
||||
retryIntervalMs?: number;
|
||||
}
|
||||
|
||||
const body = await res.body.json();
|
||||
const spec = await dereference(body as OpenAPIV2.Document);
|
||||
const hashes = generateSpecHashes(spec);
|
||||
log.debug("api", "OpenAPI hashes updated (%d endpoints)", Object.keys(hashes).length);
|
||||
return hashes;
|
||||
} catch (error) {
|
||||
if (isApiError(error)) {
|
||||
log.debug("api", "Failed to fetch OpenAPI spec: %d", error.statusCode);
|
||||
}
|
||||
const DEFAULT_RETRY_INTERVAL_MS = 30_000;
|
||||
|
||||
return null;
|
||||
export async function createHeadscale(opts: CreateHeadscaleOptions): Promise<Headscale> {
|
||||
const transport = await createTransport({ url: opts.url, certPath: opts.certPath });
|
||||
const retryIntervalMs = opts.retryIntervalMs ?? DEFAULT_RETRY_INTERVAL_MS;
|
||||
|
||||
let version: ServerVersion = parseServerVersion("unreachable");
|
||||
let capabilities: Capabilities = capabilitiesFor(version);
|
||||
let detected = false;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let disposed = false;
|
||||
|
||||
function settle(parsed: ServerVersion) {
|
||||
version = parsed;
|
||||
capabilities = capabilitiesFor(parsed);
|
||||
detected = true;
|
||||
if (parsed.unknown) {
|
||||
log.warn(
|
||||
"api",
|
||||
"Could not parse Headscale version %s, assuming newest known capabilities",
|
||||
parsed.raw,
|
||||
);
|
||||
} else {
|
||||
log.info("api", "Connected to Headscale %s", formatServerVersion(parsed));
|
||||
}
|
||||
}
|
||||
|
||||
const isAtleast = (version: Version): boolean => {
|
||||
return isAtLeast(apiVersion, version);
|
||||
};
|
||||
|
||||
openapiHashes = await fetchAndHashOpenapi();
|
||||
apiVersion = detectApiVersion(openapiHashes);
|
||||
|
||||
setInterval(async () => {
|
||||
const hashes = await fetchAndHashOpenapi();
|
||||
if (hashes) {
|
||||
openapiHashes = hashes;
|
||||
apiVersion = detectApiVersion(openapiHashes);
|
||||
async function detectOnce(): Promise<boolean> {
|
||||
try {
|
||||
const { version: raw } = await transport.getPublic<{ version: string }>("/version");
|
||||
settle(parseServerVersion(raw));
|
||||
return true;
|
||||
} catch (error) {
|
||||
// 404 means Headscale is reachable but predates 0.27.0 (where
|
||||
// /version was introduced). That server is below the supported
|
||||
// floor, so we don't settle — leave capabilities permissive and
|
||||
// keep retrying in case the operator upgrades in place.
|
||||
if (isDataWithApiError(error) && error.data.statusCode === 404) {
|
||||
log.error(
|
||||
"api",
|
||||
"Headscale /version returned 404; Headplane requires Headscale %s or newer",
|
||||
MIN_SUPPORTED_VERSION,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
log.debug("api", "Headscale /version probe failed: %s", String(error));
|
||||
return false;
|
||||
}
|
||||
}, 60_000); // every 60 seconds
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
if (disposed || detected) return;
|
||||
retryTimer = setTimeout(async () => {
|
||||
retryTimer = undefined;
|
||||
if (disposed) return;
|
||||
if (await detectOnce()) return;
|
||||
scheduleRetry();
|
||||
}, retryIntervalMs);
|
||||
// Don't keep the event loop alive on this timer alone — Headplane
|
||||
// should still shut down cleanly while we're waiting to retry.
|
||||
retryTimer.unref?.();
|
||||
}
|
||||
|
||||
if (!(await detectOnce())) {
|
||||
log.warn(
|
||||
"api",
|
||||
"Headscale unreachable at boot; defaulting to newest-known capabilities and retrying every %dms",
|
||||
retryIntervalMs,
|
||||
);
|
||||
scheduleRetry();
|
||||
}
|
||||
|
||||
return {
|
||||
undiciAgent,
|
||||
baseUrl,
|
||||
openapiHashes,
|
||||
apiVersion,
|
||||
getRuntimeClient: (apiKey: string) => {
|
||||
return endpointSets(
|
||||
{
|
||||
rawFetch,
|
||||
apiFetch,
|
||||
isAtleast,
|
||||
},
|
||||
apiKey,
|
||||
);
|
||||
// Getters so callers always observe the latest detected values
|
||||
// without having to know about the retry loop.
|
||||
get version() {
|
||||
return version;
|
||||
},
|
||||
clientHelpers: {
|
||||
rawFetch,
|
||||
apiFetch,
|
||||
isAtleast,
|
||||
get capabilities() {
|
||||
return capabilities;
|
||||
},
|
||||
health: () => transport.health(),
|
||||
client(apiKey) {
|
||||
return {
|
||||
nodes: makeNodeApi(transport, capabilities, apiKey),
|
||||
users: makeUserApi(transport, capabilities, apiKey),
|
||||
policy: makePolicyApi(transport, capabilities, apiKey),
|
||||
preAuthKeys: makePreAuthKeyApi(transport, capabilities, apiKey),
|
||||
apiKeys: makeApiKeyApi(transport, capabilities, apiKey),
|
||||
};
|
||||
},
|
||||
async dispose() {
|
||||
disposed = true;
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer);
|
||||
retryTimer = undefined;
|
||||
}
|
||||
await transport.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Undici agent for making HTTP requests.
|
||||
*
|
||||
* @param certPath Optional path to a custom TLS certificate for secure connections.
|
||||
* @returns A promise that resolves to an `Agent` instance.
|
||||
*/
|
||||
async function createUndiciAgent(certPath?: string): Promise<Agent> {
|
||||
if (!certPath) {
|
||||
return new Agent();
|
||||
}
|
||||
|
||||
try {
|
||||
log.debug("config", "Loading certificate from %s", certPath);
|
||||
const data = await readFile(certPath, "utf8");
|
||||
|
||||
log.info("config", "Using certificate from %s", certPath);
|
||||
return new Agent({ connect: { ca: data.trim() } });
|
||||
} catch (error) {
|
||||
log.error("config", "Failed to load Headscale TLS cert: %s", error);
|
||||
log.debug("config", "Error Details: %o", error);
|
||||
return new Agent();
|
||||
}
|
||||
}
|
||||
|
||||
function generateSpecHashes(spec: OpenAPIV2.Document) {
|
||||
const hashes: Record<string, string> = {};
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const [path, item] of Object.entries(spec.paths)) {
|
||||
for (const [method, operation] of Object.entries(item)) {
|
||||
if (typeof operation !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
|
||||
const raw = JSON.stringify(
|
||||
{
|
||||
path,
|
||||
method: method.toUpperCase(),
|
||||
parameters,
|
||||
responses,
|
||||
},
|
||||
Object.keys({ path, method, parameters, responses }).sort(),
|
||||
);
|
||||
|
||||
const hash = createHash("md5").update(raw).digest("hex").slice(0, 16);
|
||||
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
|
||||
seen.add(final);
|
||||
hashes[`${method.toUpperCase()} ${path}`] = final;
|
||||
}
|
||||
}
|
||||
|
||||
return hashes;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user