Compare commits

...

29 Commits

Author SHA1 Message Date
Aarnav Tale fb4b0b1404 chore: v0.7.0-beta.3 2026-05-14 13:50:54 -04:00
Aarnav Tale 1e0ff7ead6 fix: encode headscale rename path segments
(cherry picked from commit 623e7c03f1)
2026-05-14 13:47:04 -04:00
github-actions[bot] c6b6cbc122 chore: update nix pnpm deps hash 2026-04-27 03:56:34 +00:00
Aarnav Tale deb284e2b4 feat: ditch hono 2026-04-26 23:52:49 -04:00
Aarnav Tale 5a2098eea5 chore: format everything with oxfmt 2026-04-26 20:38:45 -04:00
Aarnav Tale b961b339bb feat: add support for OIDC logouts
Closes HP-407.
2026-04-26 20:36:52 -04:00
Aarnav Tale ac6f9e4f7e feat: support toggling light or dark color schemes
Fixes HP-375.
2026-04-26 20:33:48 -04:00
github-actions[bot] 3026b33834 chore: update flake.lock (#533)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-04-26 16:10:33 -04:00
Aarnav Tale ecd284b5d8 Merge pull request #537 from croatialu/feat/oidc-weak-rsa-fallback 2026-04-26 16:10:05 -04:00
Aarnav Tale 4cf4e5c040 fix: use thin scrollbars that actually work
Closes HP-536.
2026-04-26 10:15:44 -04:00
croatialu 9e5e5a613a fix: harden OIDC weak RSA fallback 2026-04-22 00:07:47 +08:00
Aarnav Tale 4d252833ef fix(ui): correctly handle mobile breakpoints for the navbar
Fixes HP-529.
2026-04-20 21:46:57 -04:00
Aarnav Tale 9238f69bfc Merge pull request #524 from tale/update_flake_lock_action 2026-04-17 16:59:14 -04:00
croatialu d110dd2bcb feat: add OIDC subject claim fallbacks 2026-04-16 18:07:57 +08:00
croatialu addef55f30 Add weak RSA OIDC verification fallback 2026-04-16 18:04:48 +08:00
github-actions[bot] 67c6c0b453 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/8d8c1fa' (2026-04-02)
  → 'github:nixos/nixpkgs/1304392' (2026-04-11)
2026-04-12 08:51:58 +00:00
Aarnav Tale 418c3bc255 docs: use cf_account_id 2026-04-11 14:56:46 -04:00
Aarnav Tale 946921fff7 docs: fix changelog 2026-04-11 14:39:49 -04:00
Aarnav Tale e4030ed254 docs: deploy stable and beta docs 2026-04-11 14:32:21 -04:00
Aarnav Tale fccd2eefc4 feat: pull local endpoints/addresses from host info 2026-04-11 12:25:17 -04:00
Aarnav Tale 93be180479 Merge pull request #527 from eccgecko/fix/zero-time-expiry-display 2026-04-10 22:04:26 -04:00
github-actions[bot] 6582f8ae07 chore: update nix pnpm deps hash 2026-04-11 01:02:59 +00:00
Aarnav Tale f0f02b3c4c fix: remove unnecessary remix-utils dependency 2026-04-10 21:01:07 -04:00
Aarnav Tale c030d1fbe4 feat: de-uglify the ACL editor 2026-04-10 21:00:52 -04:00
github-actions[bot] 724e454466 chore: update nix pnpm deps hash 2026-04-11 00:39:54 +00:00
Aarnav Tale dc80c93184 chore: update deps 2026-04-10 20:38:26 -04:00
eccgecko c164e07336 fix: show 'Never' for Go zero-time expiry on detail page
The 'Key expiry' attribute only checked node.expiry !== null, showing a
garbled date for the '0001-01-01T00:00:00Z' zero-time that headscale
may return for tagged nodes (juanfont/headscale#3170).
2026-04-11 00:08:28 +01:00
eccgecko d26c23313c fix: show "No expiry" badge for Go zero-time expiry
uiTagsForNode() only checked node.expiry === null, missing Go
zero-time that headscale returns for tagged nodes after a
restart (juanfont/headscale#3170).

The !node.expired guard is technically redundant with isNoExpiry but
documents intent: "No expiry" is only shown for nodes that never
had an expiry set
2026-04-11 00:07:32 +01:00
eccgecko d5f76637f5 refactor: extract isNoExpiry utility for zero-time handling
Go's time.Time{} serialises to '0001-01-01T00:00:00Z' which headscale
may return instead of null for tagged nodes (juanfont/headscale#3170).

Commit extracts the the existing inline check into a shared function for reuse.

No behavior change — mapNodes() already handled both variants.
2026-04-11 00:00:37 +01:00
107 changed files with 5615 additions and 3834 deletions
+84
View File
@@ -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
+1
View File
@@ -1 +1,2 @@
side-effects-cache = false
public-hoist-pattern[]=vue
+31 -43
View File
@@ -1,58 +1,46 @@
# 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 Browser SSH feature**
- Should now work with custom DERP ports and properly handle sessions.
- Switched to using `libghostty` for a proper, modern terminal experience (closes [#515](https://github.com/tale/headplane/issues/515)).
- Added more resilient error handling and state handling when initiating connections.
- **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.
- **Replaced `openid-client` with a clean-room OIDC implementation.**
- Removed the `openid-client` dependency entirely.
- Fixed `client_secret_basic` auth method not working with Google SSO and other providers (closes [#493](https://github.com/tale/headplane/issues/493)).
- Fixed OIDC connector initialization failures on beta.1 (closes [#516](https://github.com/tale/headplane/issues/516)).
- **Rearchitected the Headplane Agent** with a new 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 and fetches all peer hostinfo as JSON.
- 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)).
- **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)).
- Store OIDC profile pictures in the database to prevent cookie header overload (closes [#326](https://github.com/tale/headplane/issues/326), via [#510](https://github.com/tale/headplane/pull/510)).
- Fixed pre-auth key expiration on Headscale 0.28+ (closes [#519](https://github.com/tale/headplane/issues/519)).
- Fixed OIDC subject matching for providers that use special characters in user IDs (e.g. Auth0 `github|12345`) (closes [#428](https://github.com/tale/headplane/issues/428)).
- Fixed `headscale.api_key` not being used consistently across all code paths.
- Fixed intermittent SSR crash on the Access Control page caused by client-only CodeMirror imports.
- 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 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 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.
- **Rewrote the WebSSH WASM module** to match Tailscale's proven `tsconnect` init sequence.
- Switched the terminal renderer from xterm.js to [restty](https://restty.dev) (Ghostty WASM).
- Bundled self-hosted JetBrains Mono Nerd Font with Nerd Fonts symbol fallback — no CDN dependency.
- Fixed SSH sessions failing with EOF: the SSH channel multiplexer was not receiving server traffic.
- Fixed terminal resize sending swapped rows/cols, causing garbled output on window resize.
- Fixed `log.Fatal()` calls in the WASM bridge killing the entire runtime on recoverable errors.
- Fixed `Close()` returning `true` on error and `false` on success.
- Fixed stale closure bug in the NodeKey tracking callback.
- Removed unnecessary `LoginDefault` and `LocalBackendStartKeyOSNeutral` control flags.
- Added cancellation support for in-flight SSH connections on close.
- Fixed WebSSH dropping DERP port information on non-standard ports (e.g. `:8443`), which caused connections to fail (closes [#515](https://github.com/tale/headplane/issues/515)).
- Fixed WebSSH WASM prefix paths for correct asset loading (closes [#386](https://github.com/tale/headplane/issues/386)).
- Fixed Nix WASM build applying DERP patch to wrong vendor directory.
- 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)).
- 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)).
- Detect unsupported Docker API versions early with a clear error message (via [#497](https://github.com/tale/headplane/pull/497)).
- Updated NixOS module options: removed deprecated agent fields, added `headscale.api_key_path` and `integration.agent.executable_path`.
---
+4
View File
@@ -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.
+18 -12
View File
@@ -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>
+3 -3
View File
@@ -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,
)}
+9 -9
View File
@@ -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>,
);
});
+3 -3
View File
@@ -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;
+4
View File
@@ -0,0 +1,4 @@
// Globals replaced at build time by Vite (`define` in `vite.config.ts`).
declare const __PREFIX__: string;
declare const __VERSION__: string;
+87 -7
View File
@@ -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>
);
}
+29 -9
View File
@@ -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>
+4 -1
View File
@@ -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
+61 -100
View File
@@ -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>
);
}
+11 -29
View File
@@ -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>
);
}
+63
View File
@@ -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)];
+2 -2
View File
@@ -108,12 +108,12 @@ export default function Page({ loaderData: { access, writable, policy } }: Route
</TabsTab>
</TabsList>
<TabsPanel value="edit">
<Suspense fallback={<Fallback acl={codePolicy} />}>
<Suspense fallback={<Fallback />}>
<LazyEditor isDisabled={disabled} onChange={setCodePolicy} value={codePolicy} />
</Suspense>
</TabsPanel>
<TabsPanel value="diff">
<Suspense fallback={<Fallback acl={codePolicy} />}>
<Suspense fallback={<Fallback />}>
<LazyDiffer left={policy} right={codePolicy} />
</Suspense>
</TabsPanel>
+25 -5
View File
@@ -1,21 +1,41 @@
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?.useEndSession && context.oidc.service) {
const status = context.oidc.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 context.oidc.service.discover();
}
const endSessionUrl = context.oidc.service.buildEndSessionUrl(principal.idToken);
if (endSessionUrl) {
url = endSessionUrl;
}
}
return redirect(url, {
headers: {
+13 -5
View File
@@ -66,13 +66,21 @@ export async function loader({ request, context }: Route.LoaderArgs) {
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.oidc?.useEndSession ? identity.idToken : undefined;
return redirect("/", {
headers: {
"Set-Cookie": await context.auth.createOidcSession(userId, {
name: identity.name,
email: identity.email,
username: identity.username,
}),
"Set-Cookie": await context.auth.createOidcSession(
userId,
{
name: identity.name,
email: identity.email,
username: identity.username,
},
{ idToken },
),
},
});
}
+2 -2
View File
@@ -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");
}
@@ -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";
@@ -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");
}
+11 -4
View File
@@ -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";
@@ -281,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"
@@ -315,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 machines IP address depending on the destination."
-11
View File
@@ -1,7 +1,6 @@
import { Loader2, WifiOff } from "lucide-react";
import { useEffect, useState } from "react";
import { data, isRouteErrorResponse, type ShouldRevalidateFunction } from "react-router";
import { ExternalScriptsHandle } from "remix-utils/external-scripts";
import Button from "~/components/button";
import Card from "~/components/card";
@@ -112,16 +111,6 @@ export const links: Route.LinksFunction = () => [
},
];
export const handle: ExternalScriptsHandle = {
scripts: [
{
src: WASM_HELPER_URL,
crossOrigin: "anonymous",
preload: true,
},
],
};
export default function Page({ loaderData }: Route.ComponentProps) {
const { hostname, username, offline, node } = loaderData;
+19 -1
View File
@@ -1,4 +1,5 @@
const WASM_MODULE_URL = `${__PREFIX__}/hp_ssh.wasm`;
const WASM_HELPER_URL = `${__PREFIX__}/wasm_exec.js`;
declare global {
type HeadplaneSSHFactory = (config: HeadplaneSSHConfig) => HeadplaneSSH;
@@ -44,12 +45,29 @@ export interface TunnelSession {
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.
* It expects the Go WASM helper to be loaded, and will error if called before.
* 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);
+34
View File
@@ -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;
}
+10 -10
View File
@@ -1,14 +1,14 @@
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();
// Use a fake API key for healthcheck
const api = context.hsApi.getRuntimeClient("fake-api-key");
const healthy = await api.isHealthy();
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",
},
});
}
+53 -51
View File
@@ -1,59 +1,61 @@
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();
// Use a fake API key for healthcheck
const api = context.hsApi.getRuntimeClient("fake-api-key");
const healthy = await api.isHealthy();
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.hsApi.apiVersion : "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",
},
});
}
+2 -2
View File
@@ -1,5 +1,5 @@
import { redirect } from 'react-router';
import { redirect } from "react-router";
export async function loader() {
return redirect('/machines');
return redirect("/machines");
}
+111
View File
@@ -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 (`hsApi`)
- 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.
+50
View File
@@ -0,0 +1,50 @@
// 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);
}
const ctx = await createAppContext(config);
ctx.auth.start();
export { config };
// 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,
});
+27
View File
@@ -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",
@@ -98,12 +115,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 +142,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
View File
@@ -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);
}
}
+11 -11
View File
@@ -1,16 +1,16 @@
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
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(client: RuntimeApiClient): Promise<void> | void;
abstract get name(): string;
}
+47 -51
View File
@@ -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!);
}
}
+3 -3
View File
@@ -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 { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log";
import { Integration } from "./abstract";
@@ -4,7 +4,6 @@ import { kill } from "node:process";
import { setTimeout } from "node:timers/promises";
import type { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log";
/**
+2 -2
View File
@@ -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 { RuntimeApiClient } from "~/server/headscale/api/endpoints";
import log from "~/utils/log";
import { Integration } from "./abstract";
+149 -154
View File
@@ -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;
}
+7 -6
View File
@@ -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;
};
}
+102
View File
@@ -0,0 +1,102 @@
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 { createHeadscaleInterface } from "./headscale/api";
import { loadHeadscaleConfig } from "./headscale/config-loader";
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
import { createAgentManager } from "./hp-agent";
import { createOidcService } from "./oidc/provider";
import { createAuthService } 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 hsApi = await createHeadscaleInterface(
config.headscale.url,
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;
let agents;
if (headscaleApiKey) {
agents = await createAgentManager(
config.integration?.agent,
config.headscale.url,
hsApi.getRuntimeClient(headscaleApiKey),
hsApi.clientHelpers.isAtleast("0.28.0"),
db,
);
} else if (config.integration?.agent?.enabled) {
log.warn("agent", "Agent is enabled but no headscale.api_key is configured");
}
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 =
config.oidc && config.oidc.enabled !== false && headscaleApiKey
? {
service: 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,
}),
disableApiKeyLogin: config.oidc.disable_api_key_login,
useEndSession: config.oidc.use_end_session,
}
: undefined;
return {
config,
db,
hsApi,
headscaleApiKey,
agents,
auth,
oidc,
hsLive: createLiveStore([nodesResource, usersResource]),
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
),
integration: await loadIntegration(config.integration),
};
}
+1
View File
@@ -36,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()),
});
+13 -16
View File
@@ -1,23 +1,20 @@
import type { Key } from '~/types';
import { defineApiEndpoints } from '../factory';
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[]>;
/**
* 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,
);
getApiKeys: async () => {
const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>("GET", "v1/apikey", apiKey);
return apiKeys;
},
return apiKeys;
},
}));
+42 -46
View File
@@ -1,56 +1,54 @@
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';
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>;
/**
* 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}`,
},
});
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;
}
},
}),
);
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,
apiKeyEndpoints,
healthcheckEndpoint,
nodeEndpoints,
policyEndpoints,
preAuthKeyEndpoints,
userEndpoints,
] as const;
/**
@@ -63,7 +61,7 @@ export const endpointSets = [
* passing in different internal implementations based on the OpenAPI spec.
*/
export type RuntimeApiClient = UnionToIntersection<
ExtractApiEndpoints<(typeof endpointSets)[number]>
ExtractApiEndpoints<(typeof endpointSets)[number]>
>;
/**
@@ -73,7 +71,5 @@ export type RuntimeApiClient = UnionToIntersection<
* @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);
export default (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) =>
composeEndpoints(endpointSets, client, apiKey);
+5 -2
View File
@@ -1,7 +1,6 @@
import type { Machine } from "~/types";
import type { HeadscaleApiInterface } from "..";
import { defineApiEndpoints } from "../factory";
interface RawMachine extends Omit<Machine, "tags"> {
@@ -146,7 +145,11 @@ export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
},
renameNode: async (nodeId, newName) => {
await client.apiFetch<void>("POST", `v1/node/${nodeId}/rename/${newName}`, apiKey);
await client.apiFetch<void>(
"POST",
`v1/node/${nodeId}/rename/${encodeURIComponent(newName)}`,
apiKey,
);
},
setNodeTags: async (nodeId, tags) => {
+31 -31
View File
@@ -1,41 +1,41 @@
import { defineApiEndpoints } from '../factory';
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 }>;
/**
* 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 }>;
/**
* 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);
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,
};
},
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 });
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) };
},
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
},
}));
+71 -72
View File
@@ -1,88 +1,87 @@
import type { User } from '~/types';
import { defineApiEndpoints } from '../factory';
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[]>;
/**
* 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>;
/**
* 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>;
/**
* 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>;
/**
* 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;
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');
}
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 },
);
const { users } = await client.apiFetch<{ users: User[] }>("GET", "v1/user", apiKey, {
id,
name,
email,
});
return users;
},
return users;
},
createUser: async (username, email, displayName, pictureUrl) => {
const { user } = await client.apiFetch<{ user: User }>(
'POST',
'v1/user',
apiKey,
{ name: username, email, displayName, pictureUrl },
);
createUser: async (username, email, displayName, pictureUrl) => {
const { user } = await client.apiFetch<{ user: User }>("POST", "v1/user", apiKey, {
name: username,
email,
displayName,
pictureUrl,
});
return user;
},
return user;
},
deleteUser: async (id) => {
await client.apiFetch<void>('DELETE', `v1/user/${id}`, apiKey);
},
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,
);
},
renameUser: async (oldId, newName) => {
await client.apiFetch<void>(
"POST",
`v1/user/${oldId}/rename/${encodeURIComponent(newName)}`,
apiKey,
);
},
}));
+33 -42
View File
@@ -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);
}
+38 -43
View File
@@ -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,
};
}
+16 -23
View File
@@ -1,4 +1,4 @@
import type { HeadscaleApiInterface } from '../api';
import type { HeadscaleApiInterface } from "../api";
/**
* Creates a strongly-typed group factory for a given endpoint interface.
@@ -8,38 +8,31 @@ import type { HeadscaleApiInterface } from '../api';
*/
export interface EndpointFactory<T extends object> {
__type?: T;
(client: HeadscaleApiInterface['clientHelpers'], apiKey: string): T;
__type?: T;
(client: HeadscaleApiInterface["clientHelpers"], apiKey: string): T;
}
export function defineApiEndpoints<T extends object>(
factories: (
client: HeadscaleApiInterface['clientHelpers'],
apiKey: string,
) => T,
factories: (client: HeadscaleApiInterface["clientHelpers"], apiKey: string) => T,
): EndpointFactory<T> {
return factories;
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;
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,
factories: T,
clientHelpers: any,
apiKey: string,
): UnionToIntersection<ExtractApiEndpoints<T[number]>> {
const instances = factories.map((f) => f(clientHelpers, apiKey));
return Object.assign({}, ...instances) as any;
const instances = factories.map((f) => f(clientHelpers, apiKey));
return Object.assign({}, ...instances) as any;
}
+31 -32
View File
@@ -1,12 +1,13 @@
import { createHash } from 'node:crypto';
import { dereference } from '@readme/openapi-parser';
import { OpenAPIV2 } from 'openapi-types';
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;
[operationId: string]: string;
}
/**
@@ -17,36 +18,34 @@ export interface DocumentHash {
* @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>();
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;
}
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 { 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;
}
}
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;
return hashes;
}
+52 -69
View File
@@ -1,6 +1,6 @@
import canonicals from '~/openapi-canonical-families.json';
import hashes from '~/openapi-operation-hashes.json';
import log from '~/utils/log';
import canonicals from "~/openapi-canonical-families.json";
import hashes from "~/openapi-operation-hashes.json";
import log from "~/utils/log";
/**
* The known API versions based on operation hashes.
@@ -15,80 +15,63 @@ const VERSIONS = Object.keys(hashes) as Version[];
* @param observed - A mapping of operation identifiers to their hashes.
* @returns The detected API version.
*/
export function detectApiVersion(
observed: Record<string, string> | null,
): Version {
if (!observed) {
const latest = VERSIONS.at(-1)!;
log.warn(
'api',
'No operation hashes observed, defaulting to version %s',
latest,
);
return latest;
}
export function detectApiVersion(observed: Record<string, string> | null): Version {
if (!observed) {
const latest = VERSIONS.at(-1)!;
log.warn("api", "No operation hashes observed, defaulting to version %s", latest);
return latest;
}
let bestVersion: Version | null = null;
let bestScore = -1;
let bestVersion: Version | null = null;
let bestScore = -1;
for (const [version, known] of Object.entries(hashes) as [
Version,
Record<string, string>,
][]) {
let score = 0;
for (const [op, hash] of Object.entries(observed)) {
if (known[op] === hash) score++;
}
if (score > bestScore) {
bestVersion = version;
bestScore = score;
}
}
for (const [version, known] of Object.entries(hashes) as [Version, Record<string, string>][]) {
let score = 0;
for (const [op, hash] of Object.entries(observed)) {
if (known[op] === hash) score++;
}
if (score > bestScore) {
bestVersion = version;
bestScore = score;
}
}
if (!bestVersion || bestScore === 0) {
const latest = VERSIONS.at(-1)!;
log.warn(
'api',
'Could not determine API version, defaulting to %s',
latest,
);
return latest;
}
if (!bestVersion || bestScore === 0) {
const latest = VERSIONS.at(-1)!;
log.warn("api", "Could not determine API version, defaulting to %s", latest);
return latest;
}
if (bestScore < Object.keys(observed).length) {
log.warn(
'api',
'Partial version match: %d/%d endpoints for version %s',
bestScore,
Object.keys(observed).length,
bestVersion,
);
}
if (bestScore < Object.keys(observed).length) {
log.warn(
"api",
"Partial version match: %d/%d endpoints for version %s",
bestScore,
Object.keys(observed).length,
bestVersion,
);
}
const canonical = Object.entries(canonicals).find(([_, family]) =>
family.includes(bestVersion),
)?.[0] as Version | undefined;
const canonical = Object.entries(canonicals).find(([_, family]) =>
family.includes(bestVersion),
)?.[0] as Version | undefined;
if (!canonical) {
log.warn(
'api',
'Could not canonicalize detected version %s, using as-is',
bestVersion,
);
if (!canonical) {
log.warn("api", "Could not canonicalize detected version %s, using as-is", bestVersion);
return bestVersion;
}
return bestVersion;
}
if (canonical !== bestVersion) {
log.info(
'api',
'Canonicalizing detected version %s → %s (same schema)',
bestVersion,
canonical,
);
}
if (canonical !== bestVersion) {
log.info(
"api",
"Canonicalizing detected version %s → %s (same schema)",
bestVersion,
canonical,
);
}
return canonical;
return canonical;
}
/**
@@ -99,5 +82,5 @@ export function detectApiVersion(
* @returns True if current is at least baseline, false otherwise.
*/
export function isAtLeast(current: Version, baseline: Version) {
return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline);
return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline);
}
+84 -92
View File
@@ -1,11 +1,12 @@
import { access, constants, readFile, writeFile } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import log from '~/utils/log';
import { access, constants, readFile, writeFile } from "node:fs/promises";
import { setTimeout } from "node:timers/promises";
import log from "~/utils/log";
export interface DNSRecord {
type: 'A' | 'AAAA' | (string & {});
name: string;
value: string;
type: "A" | "AAAA" | (string & {});
name: string;
value: string;
}
// This class is solely for DNS records that are out of tree in the main
@@ -15,113 +16,104 @@ export interface DNSRecord {
// All DNS insertions and deletions are handled by the main config manager,
// but are passed through to here if the extra file is being used.
export class HeadscaleDNSConfig {
private records: DNSRecord[];
private access: 'rw' | 'ro' | 'no';
private path?: string;
private writeLock = false;
private records: DNSRecord[];
private access: "rw" | "ro" | "no";
private path?: string;
private writeLock = false;
constructor(
access: 'rw' | 'ro' | 'no',
records?: DNSRecord[],
path?: string,
) {
this.access = access;
this.records = records ?? [];
this.path = path;
}
constructor(access: "rw" | "ro" | "no", records?: DNSRecord[], path?: string) {
this.access = access;
this.records = records ?? [];
this.path = path;
}
readable() {
return this.access !== 'no';
}
readable() {
return this.access !== "no";
}
writable() {
return this.access === 'rw';
}
writable() {
return this.access === "rw";
}
get r() {
return this.records;
}
get r() {
return this.records;
}
async patch(records: DNSRecord[]) {
if (!this.path || !this.readable() || !this.writable()) {
return;
}
async patch(records: DNSRecord[]) {
if (!this.path || !this.readable() || !this.writable()) {
return;
}
this.records = records;
log.debug(
'config',
'Patching DNS records (%d -> %d)',
this.records.length,
records.length,
);
this.records = records;
log.debug("config", "Patching DNS records (%d -> %d)", this.records.length, records.length);
return this.write();
}
return this.write();
}
private async write() {
if (!this.path || !this.writable()) {
return;
}
private async write() {
if (!this.path || !this.writable()) {
return;
}
while (this.writeLock) {
await setTimeout(100);
}
while (this.writeLock) {
await setTimeout(100);
}
this.writeLock = true;
log.debug('config', 'Writing updated DNS configuration to %s', this.path);
const data = JSON.stringify(this.records, null, 4);
await writeFile(this.path, data);
this.writeLock = false;
}
this.writeLock = true;
log.debug("config", "Writing updated DNS configuration to %s", this.path);
const data = JSON.stringify(this.records, null, 4);
await writeFile(this.path, data);
this.writeLock = false;
}
}
export async function loadHeadscaleDNS(path?: string) {
if (!path) {
return;
}
if (!path) {
return;
}
log.debug('config', 'Loading Headscale DNS configuration file: %s', path);
const { w, r } = await validateConfigPath(path);
if (!r) {
return new HeadscaleDNSConfig('no');
}
log.debug("config", "Loading Headscale DNS configuration file: %s", path);
const { w, r } = await validateConfigPath(path);
if (!r) {
return new HeadscaleDNSConfig("no");
}
const records = await loadConfigFile(path);
if (!records) {
return new HeadscaleDNSConfig('no');
}
const records = await loadConfigFile(path);
if (!records) {
return new HeadscaleDNSConfig("no");
}
return new HeadscaleDNSConfig(w ? 'rw' : 'ro', records, path);
return new HeadscaleDNSConfig(w ? "rw" : "ro", records, path);
}
async function validateConfigPath(path: string) {
try {
await access(path, constants.F_OK | constants.R_OK);
log.info('config', 'Found a valid Headscale DNS file at %s', path);
} catch (error) {
log.error('config', 'Unable to read a Headscale DNS file at %s', path);
log.error('config', '%s', error);
return { w: false, r: false };
}
try {
await access(path, constants.F_OK | constants.R_OK);
log.info("config", "Found a valid Headscale DNS file at %s", path);
} catch (error) {
log.error("config", "Unable to read a Headscale DNS file at %s", path);
log.error("config", "%s", error);
return { w: false, r: false };
}
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
log.warn('config', 'Headscale DNS file at %s is not writable', path);
return { w: false, r: true };
}
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
log.warn("config", "Headscale DNS file at %s is not writable", path);
return { w: false, r: true };
}
}
async function loadConfigFile(path: string) {
log.debug('config', 'Reading Headscale DNS file at %s', path);
try {
const data = await readFile(path, 'utf8');
const records = JSON.parse(data) as DNSRecord[];
return records;
} catch (e) {
log.error('config', 'Error reading Headscale DNS file at %s', path);
log.error('config', '%s', e);
return false;
}
log.debug("config", "Reading Headscale DNS file at %s", path);
try {
const data = await readFile(path, "utf8");
const records = JSON.parse(data) as DNSRecord[];
return records;
} catch (e) {
log.error("config", "Error reading Headscale DNS file at %s", path);
log.error("config", "%s", e);
return false;
}
}
+295 -344
View File
@@ -1,422 +1,373 @@
import { constants, access, readFile, writeFile } from 'node:fs/promises';
import { exit } from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { type } from 'arktype';
import { Document, parseDocument } from 'yaml';
import log from '~/utils/log';
import { DNSRecord, HeadscaleDNSConfig, loadHeadscaleDNS } from './config-dns';
import { headscaleConfig } from './config-schema';
import { constants, access, readFile, writeFile } from "node:fs/promises";
import { exit } from "node:process";
import { setTimeout } from "node:timers/promises";
import { type } from "arktype";
import { Document, parseDocument } from "yaml";
import log from "~/utils/log";
import { DNSRecord, HeadscaleDNSConfig, loadHeadscaleDNS } from "./config-dns";
import { headscaleConfig } from "./config-schema";
interface PatchConfig {
path: string;
value: unknown;
path: string;
value: unknown;
}
// We need a class for the config because we need to be able to
// support retrieving it via a getter but also be able to
// patch it and to query it for its mode
class HeadscaleConfig {
private config?: typeof headscaleConfig.infer;
private document?: Document;
private access: 'rw' | 'ro' | 'no';
private path?: string;
private writeLock = false;
private dns?: HeadscaleDNSConfig;
private config?: typeof headscaleConfig.infer;
private document?: Document;
private access: "rw" | "ro" | "no";
private path?: string;
private writeLock = false;
private dns?: HeadscaleDNSConfig;
constructor(
access: 'rw' | 'ro' | 'no',
dns?: HeadscaleDNSConfig,
config?: typeof headscaleConfig.infer,
document?: Document,
path?: string,
) {
this.access = access;
this.config = config;
this.document = document;
this.path = path;
this.dns = dns;
}
constructor(
access: "rw" | "ro" | "no",
dns?: HeadscaleDNSConfig,
config?: typeof headscaleConfig.infer,
document?: Document,
path?: string,
) {
this.access = access;
this.config = config;
this.document = document;
this.path = path;
this.dns = dns;
}
readable() {
return this.access !== 'no';
}
readable() {
return this.access !== "no";
}
writable() {
return this.access === 'rw';
}
writable() {
return this.access === "rw";
}
get c() {
return this.config;
}
get c() {
return this.config;
}
get d() {
if (this.dns) {
return this.dns.r;
}
get d() {
if (this.dns) {
return this.dns.r;
}
return this.config?.dns.extra_records ?? [];
}
return this.config?.dns.extra_records ?? [];
}
async patch(patches: PatchConfig[]) {
if (!this.path || !this.document || !this.readable() || !this.writable()) {
return;
}
async patch(patches: PatchConfig[]) {
if (!this.path || !this.document || !this.readable() || !this.writable()) {
return;
}
log.debug('config', 'Patching Headscale configuration');
for (const patch of patches) {
const { path, value } = patch;
log.debug('config', 'Patching %s with %o', path, value);
log.debug("config", "Patching Headscale configuration");
for (const patch of patches) {
const { path, value } = patch;
log.debug("config", "Patching %s with %o", path, value);
// If the key is something like `test.bar."foo.bar"`, then we treat
// the foo.bar as a single key, and not as two keys, so that needs
// to be split correctly.
// If the key is something like `test.bar."foo.bar"`, then we treat
// the foo.bar as a single key, and not as two keys, so that needs
// to be split correctly.
// Iterate through each character, and if we find a dot, we check if
// the next character is a quote, and if it is, we skip until the next
// quote, and then we skip the next character, which should be a dot.
// If it's not a quote, we split it.
const key = [];
let current = '';
let quote = false;
// Iterate through each character, and if we find a dot, we check if
// the next character is a quote, and if it is, we skip until the next
// quote, and then we skip the next character, which should be a dot.
// If it's not a quote, we split it.
const key = [];
let current = "";
let quote = false;
for (const char of path) {
if (char === '"') {
quote = !quote;
}
for (const char of path) {
if (char === '"') {
quote = !quote;
}
if (char === '.' && !quote) {
key.push(current);
current = '';
continue;
}
if (char === "." && !quote) {
key.push(current);
current = "";
continue;
}
current += char;
}
current += char;
}
key.push(current.replaceAll('"', ''));
if (value === null) {
this.document.deleteIn(key);
continue;
}
key.push(current.replaceAll('"', ""));
if (value === null) {
this.document.deleteIn(key);
continue;
}
this.document.setIn(key, value);
}
this.document.setIn(key, value);
}
// Revalidate our configuration and update the config
// object with the new configuration
log.info('config', 'Revalidating Headscale configuration');
const config = validateConfig(this.document.toJSON());
if (!config) {
return;
}
// Revalidate our configuration and update the config
// object with the new configuration
log.info("config", "Revalidating Headscale configuration");
const config = validateConfig(this.document.toJSON());
if (!config) {
return;
}
log.debug(
'config',
'Writing updated Headscale configuration to %s',
this.path,
);
log.debug("config", "Writing updated Headscale configuration to %s", this.path);
// We need to lock the writeLock so that we don't try to write
// to the file while we're already writing to it
while (this.writeLock) {
await setTimeout(100);
}
// We need to lock the writeLock so that we don't try to write
// to the file while we're already writing to it
while (this.writeLock) {
await setTimeout(100);
}
this.writeLock = true;
await writeFile(this.path, this.document.toString(), 'utf8');
this.config = config;
this.writeLock = false;
return;
}
this.writeLock = true;
await writeFile(this.path, this.document.toString(), "utf8");
this.config = config;
this.writeLock = false;
return;
}
/**
* Adds a DNS record to the Headscale configuration.
* Differentiates between the file mode and config mode automatically.
* @param record The DNS record to add.
* @returns True if we need to restart the integration.
*/
async addDNS(record: DNSRecord) {
if (this.dns) {
if (!this.dns.readable() || !this.dns.writable()) {
log.debug('config', 'DNS config is not writable');
return;
}
/**
* Adds a DNS record to the Headscale configuration.
* Differentiates between the file mode and config mode automatically.
* @param record The DNS record to add.
* @returns True if we need to restart the integration.
*/
async addDNS(record: DNSRecord) {
if (this.dns) {
if (!this.dns.readable() || !this.dns.writable()) {
log.debug("config", "DNS config is not writable");
return;
}
const records = this.dns.r;
if (
records.some((i) => i.name === record.name && i.type === record.type)
) {
log.debug('config', 'DNS record already exists');
return;
}
const records = this.dns.r;
if (records.some((i) => i.name === record.name && i.type === record.type)) {
log.debug("config", "DNS record already exists");
return;
}
return this.dns.patch([...records, record]);
}
return this.dns.patch([...records, record]);
}
// If we get here, we need to add to the main config instead of
// a separate file (which requires an integration restart)
const existing = this.config?.dns.extra_records ?? [];
if (
existing.some((i) => i.name === record.name && i.type === record.type)
) {
log.debug('config', 'DNS record already exists');
return;
}
// If we get here, we need to add to the main config instead of
// a separate file (which requires an integration restart)
const existing = this.config?.dns.extra_records ?? [];
if (existing.some((i) => i.name === record.name && i.type === record.type)) {
log.debug("config", "DNS record already exists");
return;
}
await this.patch([
{
path: 'dns.extra_records',
value: Array.from(new Set([...existing, record])),
},
]);
await this.patch([
{
path: "dns.extra_records",
value: Array.from(new Set([...existing, record])),
},
]);
return true;
}
return true;
}
/**
* Removes a DNS record from the Headscale configuration.
* Differentiates between the file mode and config mode automatically.
* @param records The DNS record to remove.
* @returns True if we need to restart the integration.
*/
async removeDNS(record: DNSRecord) {
// In this case we need to check both the main config and the DNS config
// to see if the record exists, and if it does, we need to remove it
// from both places.
/**
* Removes a DNS record from the Headscale configuration.
* Differentiates between the file mode and config mode automatically.
* @param records The DNS record to remove.
* @returns True if we need to restart the integration.
*/
async removeDNS(record: DNSRecord) {
// In this case we need to check both the main config and the DNS config
// to see if the record exists, and if it does, we need to remove it
// from both places.
if (this.dns) {
if (!this.dns.readable() || !this.dns.writable()) {
log.debug('config', 'DNS config is not writable');
return;
}
if (this.dns) {
if (!this.dns.readable() || !this.dns.writable()) {
log.debug("config", "DNS config is not writable");
return;
}
const records = this.dns.r.filter(
(i) => i.name !== record.name || i.type !== record.type,
);
const records = this.dns.r.filter((i) => i.name !== record.name || i.type !== record.type);
return this.dns.patch(records);
}
return this.dns.patch(records);
}
// If we get here, we need to remove from the main config instead of
// a separate file (which requires an integration restart)
const existing = this.config?.dns.extra_records ?? [];
const filtered = existing.filter(
(i) => i.name !== record.name || i.type !== record.type,
);
// If we get here, we need to remove from the main config instead of
// a separate file (which requires an integration restart)
const existing = this.config?.dns.extra_records ?? [];
const filtered = existing.filter((i) => i.name !== record.name || i.type !== record.type);
// If the length of the existing records is the same as the filtered
// records, then we don't need to do anything
if (existing.length === filtered.length) {
return;
}
// If the length of the existing records is the same as the filtered
// records, then we don't need to do anything
if (existing.length === filtered.length) {
return;
}
await this.patch([
{
path: 'dns.extra_records',
value: existing.filter(
(i) => i.name !== record.name || i.type !== record.type,
),
},
]);
await this.patch([
{
path: "dns.extra_records",
value: existing.filter((i) => i.name !== record.name || i.type !== record.type),
},
]);
return true;
}
return true;
}
}
export async function loadHeadscaleConfig(
path?: string,
strict = true,
dnsPath?: string,
) {
if (!path) {
log.debug('config', 'No Headscale configuration file was provided');
return new HeadscaleConfig('no');
}
export async function loadHeadscaleConfig(path?: string, strict = true, dnsPath?: string) {
if (!path) {
log.debug("config", "No Headscale configuration file was provided");
return new HeadscaleConfig("no");
}
log.debug('config', 'Loading Headscale configuration file: %s', path);
const { r, w } = await validateConfigPath(path);
if (!r) {
return new HeadscaleConfig('no');
}
log.debug("config", "Loading Headscale configuration file: %s", path);
const { r, w } = await validateConfigPath(path);
if (!r) {
return new HeadscaleConfig("no");
}
const document = await loadConfigFile(path);
if (!document) {
return new HeadscaleConfig('no');
}
const document = await loadConfigFile(path);
if (!document) {
return new HeadscaleConfig("no");
}
if (!strict) {
return new HeadscaleConfig(
w ? 'rw' : 'ro',
new HeadscaleDNSConfig('no'),
augmentUnstrictConfig(document.toJSON()),
document,
path,
);
}
if (!strict) {
return new HeadscaleConfig(
w ? "rw" : "ro",
new HeadscaleDNSConfig("no"),
augmentUnstrictConfig(document.toJSON()),
document,
path,
);
}
const config = validateConfig(document.toJSON());
if (!config) {
return new HeadscaleConfig('no');
}
const config = validateConfig(document.toJSON());
if (!config) {
return new HeadscaleConfig("no");
}
if (config.dns.extra_records && config.dns.extra_records_path) {
log.warn(
'config',
'Both extra_records and extra_records_path are set, Headscale will crash',
);
if (config.dns.extra_records && config.dns.extra_records_path) {
log.warn("config", "Both extra_records and extra_records_path are set, Headscale will crash");
log.warn('config', 'Please remove one of them from the configuration file');
return new HeadscaleConfig('no');
}
log.warn("config", "Please remove one of them from the configuration file");
return new HeadscaleConfig("no");
}
const dns = await loadHeadscaleDNS(dnsPath);
if (dns && !config.dns.extra_records_path) {
log.error(
'config',
'Using separate DNS config file but dns.extra_records_path is not set in Headscale config',
);
log.error(
'config',
'Please set `dns.extra_records_path` in the Headscale config',
);
log.error(
'config',
'Or remove `headscale.dns_records_path` from the Headplane config',
);
const dns = await loadHeadscaleDNS(dnsPath);
if (dns && !config.dns.extra_records_path) {
log.error(
"config",
"Using separate DNS config file but dns.extra_records_path is not set in Headscale config",
);
log.error("config", "Please set `dns.extra_records_path` in the Headscale config");
log.error("config", "Or remove `headscale.dns_records_path` from the Headplane config");
exit(1);
}
exit(1);
}
return new HeadscaleConfig(w ? 'rw' : 'ro', dns, config, document, path);
return new HeadscaleConfig(w ? "rw" : "ro", dns, config, document, path);
}
async function validateConfigPath(path: string) {
try {
await access(path, constants.F_OK | constants.R_OK);
log.info(
'config',
'Found a valid Headscale configuration file at %s',
path,
);
} catch (error) {
log.error(
'config',
'Unable to read a Headscale configuration file at %s',
path,
);
log.error('config', '%s', error);
return { w: false, r: false };
}
try {
await access(path, constants.F_OK | constants.R_OK);
log.info("config", "Found a valid Headscale configuration file at %s", path);
} catch (error) {
log.error("config", "Unable to read a Headscale configuration file at %s", path);
log.error("config", "%s", error);
return { w: false, r: false };
}
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
log.warn(
'config',
'Headscale configuration file at %s is not writable',
path,
);
return { w: false, r: true };
}
try {
await access(path, constants.F_OK | constants.W_OK);
return { w: true, r: true };
} catch (error) {
log.warn("config", "Headscale configuration file at %s is not writable", path);
return { w: false, r: true };
}
}
async function loadConfigFile(path: string) {
log.debug('config', 'Reading Headscale configuration file at %s', path);
try {
const data = await readFile(path, 'utf8');
const configYaml = parseDocument(data);
if (configYaml.errors.length > 0) {
log.error(
'config',
'Cannot parse Headscale configuration file at %s',
path,
);
for (const error of configYaml.errors) {
log.error('config', ` - ${error.toString()}`);
}
log.debug("config", "Reading Headscale configuration file at %s", path);
try {
const data = await readFile(path, "utf8");
const configYaml = parseDocument(data);
if (configYaml.errors.length > 0) {
log.error("config", "Cannot parse Headscale configuration file at %s", path);
for (const error of configYaml.errors) {
log.error("config", ` - ${error.toString()}`);
}
return false;
}
return false;
}
return configYaml;
} catch (e) {
log.error(
'config',
'Error reading Headscale configuration file at %s',
path,
);
log.error('config', '%s', e);
return false;
}
return configYaml;
} catch (e) {
log.error("config", "Error reading Headscale configuration file at %s", path);
log.error("config", "%s", e);
return false;
}
}
export function validateConfig(config: unknown) {
log.debug('config', 'Validating Headscale configuration');
const result = headscaleConfig(config);
if (result instanceof type.errors) {
log.error('config', 'Error validating Headscale configuration:');
for (const [number, error] of result.entries()) {
log.error('config', ` - (${number}): ${error.toString()}`);
}
log.debug("config", "Validating Headscale configuration");
const result = headscaleConfig(config);
if (result instanceof type.errors) {
log.error("config", "Error validating Headscale configuration:");
for (const [number, error] of result.entries()) {
log.error("config", ` - (${number}): ${error.toString()}`);
}
return;
}
return;
}
return result;
return result;
}
// If config_strict is false, we set the defaults and disable
// the schema checking for the values that are not present
function augmentUnstrictConfig(loaded: Partial<typeof headscaleConfig.infer>) {
log.debug('config', 'Augmenting Headscale configuration in non-strict mode');
const config = {
...loaded,
tls_letsencrypt_cache_dir:
loaded.tls_letsencrypt_cache_dir ?? '/var/www/cache',
tls_letsencrypt_challenge_type:
loaded.tls_letsencrypt_challenge_type ?? 'HTTP-01',
grpc_listen_addr: loaded.grpc_listen_addr ?? ':50443',
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
randomize_client_port: loaded.randomize_client_port ?? false,
unix_socket: loaded.unix_socket ?? '/var/run/headscale/headscale.sock',
unix_socket_permission: loaded.unix_socket_permission ?? '0770',
log.debug("config", "Augmenting Headscale configuration in non-strict mode");
const config = {
...loaded,
tls_letsencrypt_cache_dir: loaded.tls_letsencrypt_cache_dir ?? "/var/www/cache",
tls_letsencrypt_challenge_type: loaded.tls_letsencrypt_challenge_type ?? "HTTP-01",
grpc_listen_addr: loaded.grpc_listen_addr ?? ":50443",
grpc_allow_insecure: loaded.grpc_allow_insecure ?? false,
randomize_client_port: loaded.randomize_client_port ?? false,
unix_socket: loaded.unix_socket ?? "/var/run/headscale/headscale.sock",
unix_socket_permission: loaded.unix_socket_permission ?? "0770",
log: loaded.log ?? {
level: 'info',
format: 'text',
},
log: loaded.log ?? {
level: "info",
format: "text",
},
logtail: loaded.logtail ?? {
enabled: false,
},
logtail: loaded.logtail ?? {
enabled: false,
},
prefixes: loaded.prefixes ?? {
allocation: 'sequential',
v4: '',
v6: '',
},
prefixes: loaded.prefixes ?? {
allocation: "sequential",
v4: "",
v6: "",
},
dns: loaded.dns ?? {
nameservers: {
global: [],
split: {},
},
search_domains: [],
extra_records: [],
magic_dns: false,
base_domain: 'headscale.net',
},
};
dns: loaded.dns ?? {
nameservers: {
global: [],
split: {},
},
search_domains: [],
extra_records: [],
magic_dns: false,
base_domain: "headscale.net",
},
};
log.warn('config', 'Headscale configuration was loaded in non-strict mode');
log.warn('config', 'This is very dangerous and comes with a few caveats:');
log.warn('config', ' - Headplane could very easily crash');
log.warn('config', ' - Headplane could break your Headscale installation');
log.warn(
'config',
' - The UI could throw random errors/show incorrect data',
);
log.warn("config", "Headscale configuration was loaded in non-strict mode");
log.warn("config", "This is very dangerous and comes with a few caveats:");
log.warn("config", " - Headplane could very easily crash");
log.warn("config", " - Headplane could break your Headscale installation");
log.warn("config", " - The UI could throw random errors/show incorrect data");
return config as typeof headscaleConfig.infer;
return config as typeof headscaleConfig.infer;
}
+127 -127
View File
@@ -1,150 +1,150 @@
import { type } from 'arktype';
import { type } from "arktype";
const goBool = type('boolean | "true" | "false"').pipe((v) => {
if (v === 'true') return true;
if (v === 'false') return false;
return v;
if (v === "true") return true;
if (v === "false") return false;
return v;
});
const goDuration = type('0 | string').pipe((v) => {
return v.toString();
const goDuration = type("0 | string").pipe((v) => {
return v.toString();
});
const databaseConfig = type({
type: '"sqlite" | "sqlite3"',
sqlite: {
path: 'string',
write_ahead_log: goBool.default(true),
wal_autocheckpoint: 'number = 1000',
},
type: '"sqlite" | "sqlite3"',
sqlite: {
path: "string",
write_ahead_log: goBool.default(true),
wal_autocheckpoint: "number = 1000",
},
})
.or({
type: '"postgres"',
postgres: {
host: 'string',
port: 'number | ""',
name: 'string',
user: 'string',
pass: 'string',
max_open_conns: 'number = 10',
max_idle_conns: 'number = 10',
conn_max_idle_time_secs: 'number = 3600',
ssl: goBool.default(false),
},
})
.merge({
debug: goBool.default(false),
'gorm?': {
prepare_stmt: goBool.default(true),
parameterized_queries: goBool.default(true),
skip_err_record_not_found: goBool.default(true),
slow_threshold: 'number = 1000',
},
});
.or({
type: '"postgres"',
postgres: {
host: "string",
port: 'number | ""',
name: "string",
user: "string",
pass: "string",
max_open_conns: "number = 10",
max_idle_conns: "number = 10",
conn_max_idle_time_secs: "number = 3600",
ssl: goBool.default(false),
},
})
.merge({
debug: goBool.default(false),
"gorm?": {
prepare_stmt: goBool.default(true),
parameterized_queries: goBool.default(true),
skip_err_record_not_found: goBool.default(true),
slow_threshold: "number = 1000",
},
});
// Not as strict parsing because we just need the values
// to be slightly truthy enough to safely modify them
export type HeadscaleConfig = typeof headscaleConfig.infer;
export const headscaleConfig = type({
server_url: 'string',
listen_addr: 'string',
'metrics_listen_addr?': 'string',
grpc_listen_addr: 'string = ":50433"',
grpc_allow_insecure: goBool.default(false),
noise: {
private_key_path: 'string',
},
prefixes: {
v4: 'string?',
v6: 'string?',
allocation: '"sequential" | "random" = "sequential"',
},
derp: {
server: {
enabled: goBool.default(true),
region_id: 'number?',
region_code: 'string?',
region_name: 'string?',
stun_listen_addr: 'string?',
private_key_path: 'string?',
ipv4: 'string?',
ipv6: 'string?',
automatically_add_embedded_derp_region: goBool.default(true),
},
urls: 'string[]?',
paths: 'string[]?',
auto_update_enabled: goBool.default(true),
update_frequency: goDuration.default('24h'),
},
server_url: "string",
listen_addr: "string",
"metrics_listen_addr?": "string",
grpc_listen_addr: 'string = ":50433"',
grpc_allow_insecure: goBool.default(false),
noise: {
private_key_path: "string",
},
prefixes: {
v4: "string?",
v6: "string?",
allocation: '"sequential" | "random" = "sequential"',
},
derp: {
server: {
enabled: goBool.default(true),
region_id: "number?",
region_code: "string?",
region_name: "string?",
stun_listen_addr: "string?",
private_key_path: "string?",
ipv4: "string?",
ipv6: "string?",
automatically_add_embedded_derp_region: goBool.default(true),
},
urls: "string[]?",
paths: "string[]?",
auto_update_enabled: goBool.default(true),
update_frequency: goDuration.default("24h"),
},
disable_check_updates: goBool.default(false),
ephemeral_node_inactivity_timeout: goDuration.default('30m'),
database: databaseConfig,
disable_check_updates: goBool.default(false),
ephemeral_node_inactivity_timeout: goDuration.default("30m"),
database: databaseConfig,
acme_url: 'string = "https://acme-v02.api.letsencrypt.org/directory"',
acme_email: 'string = ""',
tls_letsencrypt_hostname: 'string = ""',
tls_letsencrypt_cache_dir: 'string = "/var/lib/headscale/cache"',
tls_letsencrypt_challenge_type: 'string = "HTTP-01"',
tls_letsencrypt_listen: 'string = ":http"',
'tls_cert_path?': 'string',
'tls_key_path?': 'string',
acme_url: 'string = "https://acme-v02.api.letsencrypt.org/directory"',
acme_email: 'string = ""',
tls_letsencrypt_hostname: 'string = ""',
tls_letsencrypt_cache_dir: 'string = "/var/lib/headscale/cache"',
tls_letsencrypt_challenge_type: 'string = "HTTP-01"',
tls_letsencrypt_listen: 'string = ":http"',
"tls_cert_path?": "string",
"tls_key_path?": "string",
log: type({
format: 'string = "text"',
level: 'string = "info"',
}).default(() => ({ format: 'text', level: 'info' })),
log: type({
format: 'string = "text"',
level: 'string = "info"',
}).default(() => ({ format: "text", level: "info" })),
'policy?': {
mode: '"database" | "file" = "file"',
path: 'string?',
},
"policy?": {
mode: '"database" | "file" = "file"',
path: "string?",
},
dns: {
magic_dns: goBool.default(true),
base_domain: 'string = "headscale.net"',
override_local_dns: goBool.default(false),
nameservers: type({
global: type('string[]').default(() => []),
split: type('Record<string, string[]>').default(() => ({})),
}).default(() => ({ global: [], split: {} })),
search_domains: type('string[]').default(() => []),
extra_records: type({
name: 'string',
value: 'string',
type: 'string | "A"',
})
.array()
.optional(),
extra_records_path: 'string?',
},
dns: {
magic_dns: goBool.default(true),
base_domain: 'string = "headscale.net"',
override_local_dns: goBool.default(false),
nameservers: type({
global: type("string[]").default(() => []),
split: type("Record<string, string[]>").default(() => ({})),
}).default(() => ({ global: [], split: {} })),
search_domains: type("string[]").default(() => []),
extra_records: type({
name: "string",
value: "string",
type: 'string | "A"',
})
.array()
.optional(),
extra_records_path: "string?",
},
unix_socket: 'string?',
unix_socket_permission: 'string = "0770"',
unix_socket: "string?",
unix_socket_permission: 'string = "0770"',
'oidc?': {
only_start_if_oidc_is_available: goBool.default(false),
issuer: 'string',
client_id: 'string',
client_secret: 'string?',
client_secret_path: 'string?',
expiry: goDuration.default('180d'),
use_expiry_from_token: goBool.default(false),
scope: type('string[]').default(() => ['openid', 'email', 'profile']),
extra_params: 'Record<string, string>?',
allowed_domains: 'string[]?',
allowed_groups: 'string[]?',
allowed_users: 'string[]?',
'pkce?': {
enabled: goBool.default(false),
method: 'string = "S256"',
},
map_legacy_users: goBool.default(false),
},
"oidc?": {
only_start_if_oidc_is_available: goBool.default(false),
issuer: "string",
client_id: "string",
client_secret: "string?",
client_secret_path: "string?",
expiry: goDuration.default("180d"),
use_expiry_from_token: goBool.default(false),
scope: type("string[]").default(() => ["openid", "email", "profile"]),
extra_params: "Record<string, string>?",
allowed_domains: "string[]?",
allowed_groups: "string[]?",
allowed_users: "string[]?",
"pkce?": {
enabled: goBool.default(false),
method: 'string = "S256"',
},
map_legacy_users: goBool.default(false),
},
'logtail?': {
enabled: goBool.default(false),
},
"logtail?": {
enabled: goBool.default(false),
},
randomize_client_port: goBool.default(false),
randomize_client_port: goBool.default(false),
});
-177
View File
@@ -1,177 +0,0 @@
import { join } from "node:path";
import { exit, versions } from "node:process";
import { createHonoServer } from "react-router-hono-server/node";
import log from "~/utils/log";
import { loadIntegration } from "./config/integration";
import { loadConfig } from "./config/load";
import { createDbClient } from "./db/client.server";
import { createHeadscaleInterface } from "./headscale/api";
import { loadHeadscaleConfig } from "./headscale/config-loader";
import { createLiveStore, nodesResource, usersResource } from "./headscale/live-store";
import { createAgentManager } from "./hp-agent";
import { createAuthService } from "./web/auth";
declare global {
const __PREFIX__: string;
const __VERSION__: string;
}
// MARK: Side-Effects
// This module contains a side-effect because everything running here
// exists for the lifetime of the process, making it appropriate.
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);
}
exit(1);
}
const db = await createDbClient(join(config.server.data_path, "hp_persist.db"));
const hsApi = await createHeadscaleInterface(config.headscale.url, 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 = headscaleApiKey
? await createAgentManager(
config.integration?.agent,
config.headscale.url,
hsApi.getRuntimeClient(headscaleApiKey),
hsApi.clientHelpers.isAtleast("0.28.0"),
db,
)
: (() => {
if (config.integration?.agent?.enabled) {
log.warn("agent", "Agent is enabled but no headscale.api_key is configured");
}
return undefined;
})();
// We also use this file to load anything needed by the react router code.
// These are usually per-request things that we need access to, like the
// helper that can issue and revoke cookies.
export type LoadContext = typeof appLoadContext;
import "react-router";
import { HeadplaneConfig } from "./config/config-schema";
import { ConfigError } from "./config/error";
import { createOidcService } from "./oidc/provider";
declare module "react-router" {
interface AppLoadContext extends LoadContext {}
}
const hsLive = createLiveStore([nodesResource, usersResource]);
const appLoadContext = {
config,
hsLive,
hs: await loadHeadscaleConfig(
config.headscale.config_path,
config.headscale.config_strict,
config.headscale.dns_records_path,
),
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,
},
}),
headscaleApiKey,
hsApi,
agents,
integration: await loadIntegration(config.integration),
oidc:
config.oidc && config.oidc.enabled !== false && headscaleApiKey
? {
service: 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,
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,
extraParams: config.oidc.extra_params,
profilePictureSource: config.oidc.profile_picture_source,
}),
disableApiKeyLogin: config.oidc.disable_api_key_login,
}
: undefined,
db,
};
declare module "react-router" {
interface AppLoadContext extends LoadContext {}
}
export default createHonoServer({
overrideGlobalObjects: true,
port: config.server.port,
hostname: config.server.host,
beforeAll: async (app) => {
app.use(__PREFIX__, async (c) => {
return c.redirect(`${__PREFIX__}/`);
});
},
serveStaticOptions: {
publicAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""),
},
clientAssets: {
// This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory.
rewriteRequestPath: (path) => path.replace(`${__PREFIX__}`, ""),
},
},
// Only log in development mode
defaultLogger: import.meta.env.DEV,
getLoadContext() {
// TODO: This is the place where we can handle reverse proxy translation
// This is better than doing it in the OIDC client, since we can do it
// for all requests, not just OIDC ones.
return appLoadContext;
},
listeningListener(info) {
log.info("server", "Running on %s:%s", info.address, info.port);
},
});
appLoadContext.auth.start();
process.on("SIGINT", () => {
log.info("server", "Received SIGINT, shutting down...");
process.exit(0);
});
process.on("SIGTERM", () => {
log.info("server", "Received SIGTERM, shutting down...");
process.exit(0);
});
+30
View File
@@ -0,0 +1,30 @@
// MARK: Production Bootstrap
//
// The production SSR build entry. Imports the React Router request
// listener from `./app`, wraps it with static-asset serving (out of
// `build/client`) and basename redirect, then binds an http(s) server.
//
// This file is NOT loaded in dev — `react-router dev` boots through
// Vite, and the dev-only `runtime/vite-plugin.ts` dispatches requests
// straight to `./app`'s default export.
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { composeListener, startHttpServer } from "../../runtime/http";
import requestListener, { config } from "./app";
// `import.meta.url` resolves to `build/server/index.js`; the built
// client lives next to it at `build/client/`.
const clientDir = resolve(dirname(fileURLToPath(import.meta.url)), "../client");
startHttpServer({
host: config.server.host,
port: config.server.port,
listener: composeListener({
basename: __PREFIX__,
staticRoot: clientDir,
immutableAssets: true,
requestListener,
}),
});
+407 -16
View File
@@ -1,4 +1,4 @@
import { createHash, randomBytes } from "node:crypto";
import { createHash, createPublicKey, randomBytes, verify } from "node:crypto";
import { createRemoteJWKSet, errors as joseErrors, jwtVerify } from "jose";
import type { JWSHeaderParameters, JWTPayload, FlattenedJWSInput } from "jose";
@@ -15,14 +15,18 @@ export interface OidcConfig {
authorizationEndpoint?: string;
tokenEndpoint?: string;
userinfoEndpoint?: string;
endSessionEndpoint?: string;
jwksUri?: string;
tokenEndpointAuthMethod?: "client_secret_basic" | "client_secret_post";
usePkce?: boolean;
scope?: string;
subjectClaims?: string[];
allowWeakRsaKeys?: boolean;
extraParams?: Record<string, string>;
profilePictureSource?: "oidc" | "gravatar";
postLogoutRedirectUri?: string;
}
export interface ResolvedEndpoints {
@@ -47,6 +51,7 @@ export interface OidcIdentity {
username: string;
email?: string;
picture?: string;
idToken?: string;
}
export type OidcErrorCode =
@@ -87,6 +92,8 @@ export interface OidcService {
flowState: OidcFlowState,
): Promise<Result<OidcIdentity, OidcError>>;
buildEndSessionUrl(idToken?: string): string | undefined;
invalidate(): void;
reload(config: OidcConfig): void;
}
@@ -114,6 +121,23 @@ interface TokenErrorResponse {
error_description?: string;
}
interface JwksResponse {
keys?: Array<JsonWebKey & { kid?: string }>;
}
interface DecodedJwtParts {
header: Record<string, unknown>;
payload: OidcClaims;
signature: Buffer;
signingInput: string;
}
interface WeakRsaContext {
alg: "RS256" | "RS384" | "RS512";
decoded: DecodedJwtParts;
candidateKeys: Array<JsonWebKey & { kid?: string }>;
}
export function createOidcService(initialConfig: OidcConfig): OidcService {
let config = Object.freeze({ ...initialConfig });
@@ -122,6 +146,13 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
let jwks: JwksResolver | undefined;
let resolvedAuthMethod: "client_secret_basic" | "client_secret_post" | undefined =
initialConfig.tokenEndpointAuthMethod;
const weakJwksCache = new Map<
string,
{ expiresAt: number; keys: Array<JsonWebKey & { kid?: string }> }
>();
let hasWarnedWeakKeyMode = false;
maybeWarnWeakRsaMode(config);
function status(): ReturnType<OidcService["status"]> {
if (lastError) {
@@ -147,6 +178,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
tokenEndpoint: config.tokenEndpoint!,
jwksUri: config.jwksUri!,
userinfoEndpoint: config.userinfoEndpoint,
endSessionEndpoint: config.endSessionEndpoint,
};
lastError = undefined;
@@ -221,7 +253,8 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
const jwksUri = config.jwksUri ?? (metadata.jwks_uri as string | undefined);
const userinfoEndpoint =
config.userinfoEndpoint ?? (metadata.userinfo_endpoint as string | undefined);
const endSessionEndpoint = metadata.end_session_endpoint as string | undefined;
const endSessionEndpoint =
config.endSessionEndpoint ?? (metadata.end_session_endpoint as string | undefined);
if (!authorizationEndpoint || !tokenEndpoint || !jwksUri) {
const missing: string[] = [];
@@ -355,7 +388,16 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
const claims = verifyResult.value;
const enriched = await enrichWithUserInfo(resolved.value, tokens.access_token, claims);
return ok(buildIdentity(enriched));
if (!resolveSubject(enriched)) {
return err({
code: "missing_sub",
message: "ID token and userinfo response are missing all configured subject claims",
hint: `Your identity provider did not return a stable user identifier. Configure oidc.subject_claims or ensure one of these claims is present: ${getSubjectClaimOrder().join(", ")}.`,
});
}
return ok(buildIdentity(enriched, tokens.id_token));
}
async function exchangeCode(
@@ -523,14 +565,6 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
clockTolerance: 60,
});
if (!payload.sub) {
return err({
code: "missing_sub",
message: "ID token is missing the 'sub' claim",
hint: "Your identity provider did not return a user identifier. Check that your OIDC client is configured to include the 'sub' claim.",
});
}
if (payload.nonce !== expectedNonce) {
return err({
code: "nonce_mismatch",
@@ -555,6 +589,28 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
});
}
let weakRsaContext: WeakRsaContext | undefined;
try {
weakRsaContext = await getWeakRsaContext(idToken);
} catch (weakRsaCause) {
return err({
code: "invalid_id_token",
message: `ID token verification failed: ${weakRsaCause instanceof Error ? weakRsaCause.message : String(weakRsaCause)}`,
});
}
if (weakRsaContext) {
if (!config.allowWeakRsaKeys) {
return err({
code: "invalid_id_token",
message: "ID token was signed with a weak RSA key that Headplane rejects by default",
hint: "If your provider cannot rotate to a 2048-bit-or-larger RSA signing key, set oidc.allow_weak_rsa_keys to true as a temporary compatibility fallback.",
});
}
return verifyIdTokenWithWeakRsa(weakRsaContext, expectedNonce);
}
if (cause instanceof joseErrors.JWSSignatureVerificationFailed) {
return err({
code: "invalid_id_token",
@@ -570,13 +626,76 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
}
}
async function verifyIdTokenWithWeakRsa(
weakRsaContext: WeakRsaContext,
expectedNonce: string,
): Promise<Result<OidcClaims, OidcError>> {
for (const jwk of weakRsaContext.candidateKeys) {
try {
const key = createPublicKey({ key: jwk, format: "jwk" });
const isValid = verify(
getNodeVerifyAlgorithm(weakRsaContext.alg),
Buffer.from(weakRsaContext.decoded.signingInput),
key,
weakRsaContext.decoded.signature,
);
if (!isValid) {
continue;
}
} catch (cause) {
return err({
code: "invalid_id_token",
message: `ID token verification failed: ${cause instanceof Error ? cause.message : String(cause)}`,
});
}
if (!hasWarnedWeakKeyMode) {
hasWarnedWeakKeyMode = true;
log.warn(
"auth",
"OIDC issuer %s is using a weak RSA signing key. Accepting it only because oidc.allow_weak_rsa_keys=true.",
config.issuer,
);
}
const claimError = validateOidcClaims(
weakRsaContext.decoded.payload,
config.issuer,
config.clientId,
60,
);
if (claimError) {
return err(claimError);
}
if (weakRsaContext.decoded.payload.nonce !== expectedNonce) {
return err({
code: "nonce_mismatch",
message: `Nonce mismatch: expected ${expectedNonce}, got ${weakRsaContext.decoded.payload.nonce}`,
hint: "Please try signing in again. This can happen with stale browser sessions.",
});
}
return ok(weakRsaContext.decoded.payload);
}
return err({
code: "invalid_id_token",
message: "ID token signature verification failed",
hint: "The identity provider's signing keys may have changed. Try restarting Headplane to refresh the key cache.",
});
}
async function enrichWithUserInfo(
ep: ResolvedEndpoints,
accessToken: string,
claims: OidcClaims,
): Promise<OidcClaims> {
const needsEnrichment = !claims.name && !claims.email && !claims.picture;
if (!needsEnrichment || !ep.userinfoEndpoint) {
const needsEnrichment =
!claims.name && !claims.email && !claims.picture && !!resolveSubject(claims);
const needsSubjectEnrichment = !resolveSubject(claims);
if ((!needsEnrichment && !needsSubjectEnrichment) || !ep.userinfoEndpoint) {
return claims;
}
@@ -595,8 +714,17 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
}
const userInfo = (await response.json()) as Record<string, unknown>;
const subjectClaimValues = Object.fromEntries(
getSubjectClaimOrder()
.filter((claim) => claim !== "sub")
.map((claim) => [
claim,
readClaimAsString(claims, claim) ?? readClaimAsString(userInfo, claim),
]),
);
return {
...claims,
...subjectClaimValues,
name: claims.name ?? (userInfo.name as string | undefined),
given_name: claims.given_name ?? (userInfo.given_name as string | undefined),
family_name: claims.family_name ?? (userInfo.family_name as string | undefined),
@@ -604,6 +732,7 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
claims.preferred_username ?? (userInfo.preferred_username as string | undefined),
email: claims.email ?? (userInfo.email as string | undefined),
picture: claims.picture ?? (userInfo.picture as string | undefined),
sub: claims.sub ?? readClaimAsString(userInfo, "sub"),
};
} catch (cause) {
log.debug(
@@ -616,7 +745,12 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
}
}
function buildIdentity(claims: OidcClaims): OidcIdentity {
function buildIdentity(claims: OidcClaims, idToken?: string): OidcIdentity {
const subject = resolveSubject(claims);
if (!subject) {
throw new Error("OIDC subject was not resolved before identity construction");
}
const name =
claims.name ??
(claims.given_name && claims.family_name
@@ -637,14 +771,34 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
return {
issuer: config.issuer,
subject: claims.sub!,
subject,
name,
username,
email: claims.email,
picture,
idToken,
};
}
function buildEndSessionUrl(idToken?: string): string | undefined {
if (!endpoints?.endSessionEndpoint) {
return undefined;
}
const params = new URLSearchParams();
if (idToken) {
params.set("id_token_hint", idToken);
}
params.set("client_id", config.clientId);
const postLogoutRedirectUri =
config.postLogoutRedirectUri ?? new URL(`${__PREFIX__}/login?s=logout`, config.baseUrl).href;
params.set("post_logout_redirect_uri", postLogoutRedirectUri);
return `${endpoints.endSessionEndpoint}?${params.toString()}`;
}
function invalidate(): void {
endpoints = undefined;
lastError = undefined;
@@ -654,10 +808,112 @@ export function createOidcService(initialConfig: OidcConfig): OidcService {
function reload(newConfig: OidcConfig): void {
config = Object.freeze({ ...newConfig });
maybeWarnWeakRsaMode(config);
invalidate();
}
return { status, discover, startFlow, handleCallback, invalidate, reload };
function getSubjectClaimOrder(): string[] {
return [
"sub",
...normalizeSubjectClaims(config.subjectClaims).filter((claim) => claim !== "sub"),
];
}
function resolveSubject(claims: OidcClaims): string | undefined {
for (const claim of getSubjectClaimOrder()) {
const value = readClaimAsString(claims, claim);
if (value) {
return value;
}
}
return undefined;
}
return {
status,
discover,
startFlow,
handleCallback,
buildEndSessionUrl,
invalidate,
reload,
};
function maybeWarnWeakRsaMode(currentConfig: OidcConfig): void {
if (!currentConfig.allowWeakRsaKeys) {
return;
}
log.warn(
"auth",
"OIDC weak RSA compatibility mode is enabled for issuer %s. This lowers token verification security and should only be used as a temporary workaround.",
currentConfig.issuer,
);
}
async function getWeakRsaContext(idToken: string): Promise<WeakRsaContext | undefined> {
if (!endpoints?.jwksUri) {
return undefined;
}
const decoded = decodeJwtParts(idToken);
const alg = decoded.header.alg;
if (alg !== "RS256" && alg !== "RS384" && alg !== "RS512") {
return undefined;
}
const keys = await fetchSigningJwks(endpoints.jwksUri);
const candidateKeys = selectCandidateSigningKeys(keys, decoded.header.kid).filter((jwk) =>
isWeakRsaKey(jwk),
);
if (candidateKeys.length === 0) {
return undefined;
}
return { alg, decoded, candidateKeys };
}
async function fetchSigningJwks(jwksUri: string): Promise<Array<JsonWebKey & { kid?: string }>> {
const cached = weakJwksCache.get(jwksUri);
const now = Date.now();
if (cached && cached.expiresAt > now) {
return cached.keys;
}
const response = await fetch(jwksUri, {
headers: { Accept: "application/json" },
signal: AbortSignal.timeout(10_000),
});
if (!response.ok) {
throw new Error(`JWKS endpoint returned ${response.status}: ${jwksUri}`);
}
const json = (await response.json()) as JwksResponse;
const keys = Array.isArray(json.keys) ? json.keys : [];
if (keys.length === 0) {
throw new Error("JWKS response did not contain any keys");
}
weakJwksCache.set(jwksUri, {
expiresAt: now + 60_000,
keys,
});
return keys;
}
}
function readClaimAsString(claims: Record<string, unknown>, claimName: string): string | undefined {
const value = claims[claimName];
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function generateRandom(bytes = 32): string {
@@ -667,3 +923,138 @@ function generateRandom(bytes = 32): string {
function computeS256Challenge(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url");
}
function decodeJwtParts(token: string): DecodedJwtParts {
const parts = token.split(".");
if (parts.length !== 3) {
throw new Error("JWT must have exactly 3 parts");
}
const [encodedHeader, encodedPayload, encodedSignature] = parts;
const header = JSON.parse(Buffer.from(encodedHeader, "base64url").toString("utf8")) as Record<
string,
unknown
>;
const payload = JSON.parse(
Buffer.from(encodedPayload, "base64url").toString("utf8"),
) as OidcClaims;
const signature = Buffer.from(encodedSignature, "base64url");
return {
header,
payload,
signature,
signingInput: `${encodedHeader}.${encodedPayload}`,
};
}
function selectCandidateSigningKeys(
keys: Array<JsonWebKey & { kid?: string }>,
expectedKid: unknown,
): Array<JsonWebKey & { kid?: string }> {
const kid = typeof expectedKid === "string" ? expectedKid : undefined;
const rsaKeys = keys.filter((key) => key.kty === "RSA");
if (kid) {
const matchingKey = rsaKeys.find((key) => key.kid === kid);
if (matchingKey) {
return [matchingKey];
}
return [];
}
return rsaKeys;
}
function getNodeVerifyAlgorithm(alg: string): "RSA-SHA256" | "RSA-SHA384" | "RSA-SHA512" {
switch (alg) {
case "RS256":
return "RSA-SHA256";
case "RS384":
return "RSA-SHA384";
case "RS512":
return "RSA-SHA512";
default:
throw new Error(`Unsupported RSA verification algorithm: ${alg}`);
}
}
function isWeakRsaKey(jwk: JsonWebKey): boolean {
if (jwk.kty !== "RSA" || typeof jwk.n !== "string") {
return false;
}
return getRsaModulusBitLength(jwk.n) < 2048;
}
function getRsaModulusBitLength(base64UrlModulus: string): number {
const modulus = Buffer.from(base64UrlModulus, "base64url");
if (modulus.length === 0) {
return 0;
}
let leadingZeroBits = 0;
let currentByte = modulus[0];
while ((currentByte & 0x80) === 0 && leadingZeroBits < 8) {
leadingZeroBits++;
currentByte <<= 1;
}
return modulus.length * 8 - leadingZeroBits;
}
function normalizeSubjectClaims(subjectClaims?: string[]): string[] {
const seen = new Set<string>();
const normalized: string[] = [];
for (const claim of subjectClaims ?? []) {
const trimmed = claim.trim();
if (trimmed.length === 0 || seen.has(trimmed)) {
continue;
}
seen.add(trimmed);
normalized.push(trimmed);
}
return normalized;
}
function validateOidcClaims(
payload: OidcClaims,
expectedIssuer: string,
expectedAudience: string,
clockToleranceSeconds: number,
): OidcError | undefined {
if (payload.iss !== expectedIssuer) {
return {
code: "invalid_id_token",
message: 'JWT claim validation failed: iss — unexpected "iss" claim value',
};
}
const audiences = Array.isArray(payload.aud) ? payload.aud : [payload.aud];
if (!audiences.includes(expectedAudience)) {
return {
code: "invalid_id_token",
message: 'JWT claim validation failed: aud — unexpected "aud" claim value',
};
}
const now = Math.floor(Date.now() / 1000);
if (typeof payload.exp === "number" && now - clockToleranceSeconds >= payload.exp) {
return {
code: "invalid_id_token",
message: "ID token is expired",
};
}
if (typeof payload.nbf === "number" && now + clockToleranceSeconds < payload.nbf) {
return {
code: "invalid_id_token",
message: "JWT claim validation failed: nbf — token is not active yet",
};
}
return undefined;
}
+6 -2
View File
@@ -20,6 +20,7 @@ export type Principal =
| {
kind: "oidc";
sessionId: string;
idToken?: string;
user: {
id: string;
subject: string;
@@ -64,7 +65,7 @@ export interface AuthService {
createOidcSession(
userId: string,
profile: NonNullable<CookiePayload["profile"]>,
maxAge?: number,
options?: { idToken?: string; maxAge?: number },
): Promise<string>;
createApiKeySession(apiKey: string, displayName: string, maxAge: number): Promise<string>;
@@ -185,6 +186,7 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
return {
kind: "oidc",
sessionId: session.id,
idToken: session.oidc_id_token ?? undefined,
user: {
id: user.id,
subject: user.sub,
@@ -249,13 +251,15 @@ export function createAuthService(opts: AuthServiceOptions): AuthService {
async function createOidcSession(
userId: string,
profile: NonNullable<CookiePayload["profile"]>,
maxAge = opts.cookie.maxAge,
options?: { idToken?: string; maxAge?: number },
): Promise<string> {
const maxAge = options?.maxAge ?? opts.cookie.maxAge;
const sid = ulid();
await opts.db.insert(authSessions).values({
id: sid,
kind: "oidc",
user_id: userId,
oidc_id_token: options?.idToken,
expires_at: new Date(Date.now() + maxAge * 1000),
});
+125 -14
View File
@@ -2,12 +2,26 @@
@plugin "tailwindcss-animate";
/* Dark mode: respect an explicit `.dark` / `.light` class on <html>, and fall
* back to the user's OS preference when neither is set (i.e. "system"). */
@custom-variant dark {
&:where(.dark, .dark *) {
@slot;
}
@media (prefers-color-scheme: dark) {
&:where(html:not(.light):not(.dark)),
&:where(html:not(.light):not(.dark) *) {
@slot;
}
}
}
@theme {
--blur-xs: 2px;
--height-editor: calc(100vh - 20rem);
--font-sans: Inter, -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;
--font-sans: "Inter Variable", -apple-system, BlinkMacSystemFont, sans-serif;
--transition-duration-25: 25ms;
--transition-duration-50: 50ms;
@@ -55,17 +69,114 @@
}
}
@supports (scrollbar-gutter: stable) {
html {
scrollbar-gutter: stable;
@layer base {
::-webkit-scrollbar {
width: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background-color: var(--border);
border-radius: 5px;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
}
.cm-merge-theme {
height: 100% !important;
body {
font-optical-sizing: auto;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.cm-mergeView {
:root {
--cm-bg: var(--color-white);
--cm-fg: var(--color-mist-900);
--cm-caret: var(--color-mist-900);
--cm-selection: var(--color-indigo-100);
--cm-selection-match: var(--color-indigo-50);
--cm-line-highlight: var(--color-mist-100);
--cm-gutter-bg: var(--color-mist-50);
--cm-gutter-fg: var(--color-mist-400);
--cm-gutter-fg-active: var(--color-mist-700);
--cm-gutter-border: var(--color-mist-200);
--cm-comment: var(--color-mist-500);
--cm-keyword: var(--color-purple-600);
--cm-string: var(--color-emerald-700);
--cm-type: var(--color-indigo-600);
--cm-definition: var(--color-blue-600);
--cm-name: var(--color-mist-800);
--cm-variable: var(--color-cyan-700);
--cm-property: var(--color-violet-600);
--cm-atom: var(--color-orange-600);
--cm-number: var(--color-orange-600);
--cm-link: var(--color-blue-600);
--cm-bracket: var(--color-mist-500);
}
/* CodeMirror dark token vars — applied for explicit .dark or system preference
* when no explicit class is set. Mirrors the dark variant above. */
.dark {
--cm-bg: var(--color-mist-950);
--cm-fg: var(--color-mist-100);
--cm-caret: var(--color-mist-100);
--cm-selection: var(--color-indigo-900);
--cm-selection-match: var(--color-indigo-950);
--cm-line-highlight: oklch(18% 0.006 224 / 0.5);
--cm-gutter-bg: var(--color-mist-950);
--cm-gutter-fg: var(--color-mist-500);
--cm-gutter-fg-active: var(--color-mist-300);
--cm-gutter-border: var(--color-mist-800);
--cm-comment: var(--color-mist-400);
--cm-keyword: var(--color-purple-400);
--cm-string: var(--color-emerald-400);
--cm-type: var(--color-indigo-400);
--cm-definition: var(--color-blue-400);
--cm-name: var(--color-mist-200);
--cm-variable: var(--color-cyan-400);
--cm-property: var(--color-violet-400);
--cm-atom: var(--color-orange-400);
--cm-number: var(--color-orange-400);
--cm-link: var(--color-blue-400);
--cm-bracket: var(--color-mist-400);
}
@media (prefers-color-scheme: dark) {
html:not(.light):not(.dark) {
--cm-bg: var(--color-mist-950);
--cm-fg: var(--color-mist-100);
--cm-caret: var(--color-mist-100);
--cm-selection: var(--color-indigo-900);
--cm-selection-match: var(--color-indigo-950);
--cm-line-highlight: oklch(18% 0.006 224 / 0.5);
--cm-gutter-bg: var(--color-mist-950);
--cm-gutter-fg: var(--color-mist-500);
--cm-gutter-fg-active: var(--color-mist-300);
--cm-gutter-border: var(--color-mist-800);
--cm-comment: var(--color-mist-400);
--cm-keyword: var(--color-purple-400);
--cm-string: var(--color-emerald-400);
--cm-type: var(--color-indigo-400);
--cm-definition: var(--color-blue-400);
--cm-name: var(--color-mist-200);
--cm-variable: var(--color-cyan-400);
--cm-property: var(--color-violet-400);
--cm-atom: var(--color-orange-400);
--cm-number: var(--color-orange-400);
--cm-link: var(--color-blue-400);
--cm-bracket: var(--color-mist-400);
}
}
.cm-merge-theme,
.cm-mergeView,
.cm-mergeViewEditor {
height: 100% !important;
}
@@ -73,10 +184,6 @@
height: 100%;
}
.cm-mergeViewEditor {
height: 100% !important;
}
.toast-viewport {
position: fixed;
z-index: 50;
@@ -177,8 +284,12 @@
}
}
/* Weirdest class name characters but ok */
.cm-mergeView .ͼ1 .cm-scroller,
.cm-mergeView .ͼ1 {
.cm-mergeView .cm-editor,
.cm-mergeView .cm-editor .cm-scroller {
height: 100% !important;
}
.cm-mergeViewEditors {
scrollbar-color: var(--cm-gutter-border) transparent;
scrollbar-width: auto;
}
+141 -135
View File
@@ -3,209 +3,215 @@
// https://github.com/tailscale/tailscale/blob/main/tailcfg/tailcfg.go#L816
export interface HostInfo {
/**
* Custom identifier we use to determine if its an agent or not
*/
HeadplaneAgent?: boolean;
/**
* Custom identifier we use to determine if its an agent or not
*/
HeadplaneAgent?: boolean;
/** Version of this code (in version.Long format) */
IPNVersion?: string;
/** Version of this code (in version.Long format) */
IPNVersion?: string;
/** Logtail ID of frontend instance */
FrontendLogID?: string;
/** Logtail ID of frontend instance */
FrontendLogID?: string;
/** Logtail ID of backend instance */
BackendLogID?: string;
/** Logtail ID of backend instance */
BackendLogID?: string;
/** Operating system the client runs on (a version.OS value) */
OS?: string;
/** Operating system the client runs on (a version.OS value) */
OS?: string;
/**
* Version of the OS, if available.
*
* - Android: "10", "11", "12", etc.
* - iOS/macOS: "15.6.1", "12.4.0", etc.
* - Windows: "10.0.19044.1889", etc.
* - FreeBSD: "12.3-STABLE", etc.
* - Linux (pre-1.32): "Debian 10.4; kernel=xxx; container; env=kn"
* - Linux (1.32+): Kernel version, e.g., "5.10.0-17-amd64".
*/
OSVersion?: string;
/**
* Version of the OS, if available.
*
* - Android: "10", "11", "12", etc.
* - iOS/macOS: "15.6.1", "12.4.0", etc.
* - Windows: "10.0.19044.1889", etc.
* - FreeBSD: "12.3-STABLE", etc.
* - Linux (pre-1.32): "Debian 10.4; kernel=xxx; container; env=kn"
* - Linux (1.32+): Kernel version, e.g., "5.10.0-17-amd64".
*/
OSVersion?: string;
/** Whether the client is running in a container (best-effort detection) */
Container?: boolean;
/** Whether the client is running in a container (best-effort detection) */
Container?: boolean;
/** Host environment type as a string */
Env?: string;
/** Host environment type as a string */
Env?: string;
/** Distribution name (e.g., "debian", "ubuntu", "nixos") */
Distro?: string;
/** Distribution name (e.g., "debian", "ubuntu", "nixos") */
Distro?: string;
/** Distribution version (e.g., "20.04") */
DistroVersion?: string;
/** Distribution version (e.g., "20.04") */
DistroVersion?: string;
/** Distribution code name (e.g., "jammy", "bullseye") */
DistroCodeName?: string;
/** Distribution code name (e.g., "jammy", "bullseye") */
DistroCodeName?: string;
/** Used to disambiguate Tailscale clients that run using tsnet */
App?: string;
/** Used to disambiguate Tailscale clients that run using tsnet */
App?: string;
/** Whether a desktop was detected on Linux */
Desktop?: boolean;
/** Whether a desktop was detected on Linux */
Desktop?: boolean;
/** Tailscale package identifier ("choco", "appstore", etc.; empty if unknown) */
Package?: string;
/** Tailscale package identifier ("choco", "appstore", etc.; empty if unknown) */
Package?: string;
/** Mobile phone model (e.g., "Pixel 3a", "iPhone12,3") */
DeviceModel?: string;
/** Mobile phone model (e.g., "Pixel 3a", "iPhone12,3") */
DeviceModel?: string;
/** macOS/iOS APNs device token for notifications (future support for Android) */
PushDeviceToken?: string;
/** macOS/iOS APNs device token for notifications (future support for Android) */
PushDeviceToken?: string;
/** Name of the host the client runs on */
Hostname?: string;
/** Name of the host the client runs on */
Hostname?: string;
/** Indicates whether the host is blocking incoming connections */
ShieldsUp?: boolean;
/** Indicates whether the host is blocking incoming connections */
ShieldsUp?: boolean;
/** Indicates this node exists in netmap because it's owned by a shared-to user */
ShareeNode?: boolean;
/** Indicates this node exists in netmap because it's owned by a shared-to user */
ShareeNode?: boolean;
/** Indicates user has opted out of sending logs and support */
NoLogsNoSupport?: boolean;
/** Indicates user has opted out of sending logs and support */
NoLogsNoSupport?: boolean;
/** Indicates the node wants the option to receive ingress connections */
WireIngress?: boolean;
/** Indicates the node wants the option to receive ingress connections */
WireIngress?: boolean;
/** Indicates node has opted-in to admin-console-driven remote updates */
AllowsUpdate?: boolean;
/** Indicates node has opted-in to admin-console-driven remote updates */
AllowsUpdate?: boolean;
/** Current host's machine type (e.g., uname -m) */
Machine?: string;
/** Current host's machine type (e.g., uname -m) */
Machine?: string;
/** `GOARCH` value of the built binary */
GoArch?: string;
/** `GOARCH` value of the built binary */
GoArch?: string;
/** Architecture variant (e.g., GOARM, GOAMD64) of the built binary */
GoArchVar?: string;
/** Architecture variant (e.g., GOARM, GOAMD64) of the built binary */
GoArchVar?: string;
/** Go version the binary was built with */
GoVersion?: string;
/** Go version the binary was built with */
GoVersion?: string;
/** Set of IP ranges this client can route */
RoutableIPs?: string[];
/** Set of IP ranges this client can route */
RoutableIPs?: string[];
/** Set of ACL tags this node wants to claim */
RequestTags?: string[];
/** Set of ACL tags this node wants to claim */
RequestTags?: string[];
/** MAC addresses to send Wake-on-LAN packets to wake this node */
WoLMACs?: string[];
/** MAC addresses to send Wake-on-LAN packets to wake this node */
WoLMACs?: string[];
/** Services advertised by this machine */
Services?: Service[];
/** Services advertised by this machine */
Services?: Service[];
/** Networking information about the node */
NetInfo?: NetInfo;
/** Networking information about the node */
NetInfo?: NetInfo;
/** SSH host keys if advertised */
sshHostKeys?: string[];
/** SSH host keys if advertised */
sshHostKeys?: string[];
/** Cloud provider information (if any) */
Cloud?: string;
/** Cloud provider information (if any) */
Cloud?: string;
/** Indicates if the client is running in userspace (netstack) mode */
Userspace?: boolean;
/** Indicates if the client is running in userspace (netstack) mode */
Userspace?: boolean;
/** Indicates if the client's subnet router is running in userspace (netstack) mode */
UserspaceRouter?: boolean;
/** Indicates if the client's subnet router is running in userspace (netstack) mode */
UserspaceRouter?: boolean;
/** Indicates if the client is running the app-connector service */
AppConnector?: boolean;
/** Indicates if the client is running the app-connector service */
AppConnector?: boolean;
/** Opaque hash of the most recent list of tailnet services (indicates config updates) */
ServicesHash?: string;
/** WireGuard endpoints (public IP:port pairs) from the network map */
Endpoints?: string[];
/** Geographical location data about the Tailscale host (optional) */
Location?: Location;
/** Home DERP region ID */
HomeDERP?: number;
/** Opaque hash of the most recent list of tailnet services (indicates config updates) */
ServicesHash?: string;
/** Geographical location data about the Tailscale host (optional) */
Location?: Location;
}
/** Represents a network service advertised by a node */
interface Service {
/** Protocol type (e.g., "tcp", "udp", "peerapi4") */
Proto: string;
/** Protocol type (e.g., "tcp", "udp", "peerapi4") */
Proto: string;
/** Port number */
Port: number;
/** Port number */
Port: number;
/** Textual description of the service (usually the process name) */
Description?: string;
/** Textual description of the service (usually the process name) */
Description?: string;
}
/** Networking information for a Tailscale node */
interface NetInfo {
/** Indicates if NAT mappings vary based on destination IP */
MappingVariesByDestIP?: boolean;
/** Indicates if NAT mappings vary based on destination IP */
MappingVariesByDestIP?: boolean;
/** Indicates if the router supports hairpinning */
HairPinning?: boolean;
/** Indicates if the router supports hairpinning */
HairPinning?: boolean;
/** Indicates if the host has IPv6 internet connectivity */
WorkingIPv6?: boolean;
/** Indicates if the host has IPv6 internet connectivity */
WorkingIPv6?: boolean;
/** Indicates if the OS supports IPv6 */
OSHasIPv6?: boolean;
/** Indicates if the OS supports IPv6 */
OSHasIPv6?: boolean;
/** Indicates if the host has UDP internet connectivity */
WorkingUDP?: boolean;
/** Indicates if the host has UDP internet connectivity */
WorkingUDP?: boolean;
/** Indicates if ICMPv4 works (empty if not checked) */
WorkingICMPv4?: boolean;
/** Indicates if ICMPv4 works (empty if not checked) */
WorkingICMPv4?: boolean;
/** Indicates if there is an existing portmap open (UPnP, PMP, PCP) */
HavePortMap?: boolean;
/** Indicates if there is an existing portmap open (UPnP, PMP, PCP) */
HavePortMap?: boolean;
/** Indicates if UPnP appears present on the LAN (empty if not checked) */
UPnP?: boolean;
/** Indicates if UPnP appears present on the LAN (empty if not checked) */
UPnP?: boolean;
/** Indicates if NAT-PMP appears present on the LAN (empty if not checked) */
PMP?: boolean;
/** Indicates if NAT-PMP appears present on the LAN (empty if not checked) */
PMP?: boolean;
/** Indicates if PCP appears present on the LAN (empty if not checked) */
PCP?: boolean;
/** Indicates if PCP appears present on the LAN (empty if not checked) */
PCP?: boolean;
/** Preferred DERP region ID */
PreferredDERP?: number;
/** Preferred DERP region ID */
PreferredDERP?: number;
/** Current link type ("wired", "wifi", "mobile") */
LinkType?: string;
/** Current link type ("wired", "wifi", "mobile") */
LinkType?: string;
/** Fastest recent time to reach various DERP STUN servers (seconds) */
DERPLatency?: Record<string, number>;
/** Fastest recent time to reach various DERP STUN servers (seconds) */
DERPLatency?: Record<string, number>;
/** Firewall mode on Linux-specific configurations */
FirewallMode?: string;
/** Firewall mode on Linux-specific configurations */
FirewallMode?: string;
}
/** Represents the geographical location of a Tailscale host */
interface Location {
/** Country name (user-friendly, properly capitalized) */
Country?: string;
/** Country name (user-friendly, properly capitalized) */
Country?: string;
/** ISO 3166-1 alpha-2 country code (upper case) */
CountryCode?: string;
/** ISO 3166-1 alpha-2 country code (upper case) */
CountryCode?: string;
/** City name (user-friendly, properly capitalized) */
City?: string;
/** City name (user-friendly, properly capitalized) */
City?: string;
/** City code to disambiguate between cities (e.g., IATA, ICAO, ISO 3166-2) */
CityCode?: string;
/** City code to disambiguate between cities (e.g., IATA, ICAO, ISO 3166-2) */
CityCode?: string;
/** Latitude of the node (in degrees, optional) */
Latitude?: number;
/** Latitude of the node (in degrees, optional) */
Latitude?: number;
/** Longitude of the node (in degrees, optional) */
Longitude?: number;
/** Longitude of the node (in degrees, optional) */
Longitude?: number;
/** Priority for exit node selection (0 means no priority, negative not allowed) */
Priority?: number;
/** Priority for exit node selection (0 means no priority, negative not allowed) */
Priority?: number;
}
+5 -5
View File
@@ -1,7 +1,7 @@
export type Key = {
id: string;
prefix: string;
expiration: string;
createdAt: Date;
lastSeen: Date;
id: string;
prefix: string;
expiration: string;
createdAt: Date;
lastSeen: Date;
};
+10 -10
View File
@@ -1,13 +1,13 @@
import type { User } from './User';
import type { User } from "./User";
export interface PreAuthKey {
id: string;
key: string;
user: User | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
expiration: string;
createdAt: string;
aclTags: string[];
id: string;
key: string;
user: User | null;
reusable: boolean;
ephemeral: boolean;
used: boolean;
expiration: string;
createdAt: string;
aclTags: string[];
}
+10 -10
View File
@@ -1,13 +1,13 @@
import type { Machine } from './Machine';
import type { Machine } from "./Machine";
export interface Route {
id: string;
node: Machine;
prefix: string;
advertised: boolean;
enabled: boolean;
isPrimary: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string;
id: string;
node: Machine;
prefix: string;
advertised: boolean;
enabled: boolean;
isPrimary: boolean;
createdAt: string;
updatedAt: string;
deletedAt: string;
}
+8 -8
View File
@@ -1,10 +1,10 @@
export interface User {
id: string;
name: string;
createdAt: string;
displayName?: string;
email?: string;
providerId?: string;
provider?: string;
profilePicUrl?: string;
id: string;
name: string;
createdAt: string;
displayName?: string;
email?: string;
providerId?: string;
provider?: string;
profilePicUrl?: string;
}
+6 -6
View File
@@ -1,6 +1,6 @@
export * from './Key';
export * from './Machine';
export * from './Route';
export * from './User';
export * from './PreAuthKey';
export * from './HostInfo';
export * from "./Key";
export * from "./Machine";
export * from "./Route";
export * from "./User";
export * from "./PreAuthKey";
export * from "./HostInfo";
+2 -2
View File
@@ -1,5 +1,5 @@
import { type ClassValue, clsx } from 'clsx';
import { twMerge } from 'tailwind-merge';
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));
export default cn;
+26
View File
@@ -0,0 +1,26 @@
import { createCookie } from "react-router";
export type ColorScheme = "dark" | "light" | "system";
let cookie = createCookie("color_scheme", {
maxAge: 34560000,
sameSite: "lax",
});
export function isValidColorScheme(val: unknown): val is ColorScheme {
return typeof val === "string" && ["dark", "light", "system"].includes(val);
}
export async function getColorScheme(request: Request) {
const header = request.headers.get("Cookie");
const vals = await cookie.parse(header);
return ["dark", "light", "system"].includes(vals?.colorScheme) ? vals.colorScheme : "system";
}
export function setColorScheme(colorScheme: ColorScheme) {
if (colorScheme === "system") {
return cookie.serialize({}, { expires: new Date(0), maxAge: 0 });
}
return cookie.serialize({ colorScheme });
}
+25 -25
View File
@@ -1,36 +1,36 @@
import type { HostInfo } from '~/types';
import type { HostInfo } from "~/types";
export function getTSVersion(host: HostInfo) {
const { IPNVersion } = host;
if (!IPNVersion) {
return 'Unknown';
}
const { IPNVersion } = host;
if (!IPNVersion) {
return "Unknown";
}
// IPNVersion is <Semver>-<something>-<something>
return IPNVersion.split('-')[0];
// IPNVersion is <Semver>-<something>-<something>
return IPNVersion.split("-")[0];
}
export function getOSInfo(host: HostInfo) {
const { OS, OSVersion } = host;
// OS follows runtime.GOOS but uses iOS and macOS instead of darwin
const formattedOS = formatOS(OS);
const { OS, OSVersion } = host;
// OS follows runtime.GOOS but uses iOS and macOS instead of darwin
const formattedOS = formatOS(OS);
// Trim in case OSVersion is empty
return `${formattedOS} ${OSVersion ?? ''}`.trim();
// Trim in case OSVersion is empty
return `${formattedOS} ${OSVersion ?? ""}`.trim();
}
function formatOS(os?: string) {
switch (os) {
case 'macOS':
case 'iOS':
return os;
case 'windows':
return 'Windows';
case 'linux':
return 'Linux';
case undefined:
return 'Unknown';
default:
return os;
}
switch (os) {
case "macOS":
case "iOS":
return os;
case "windows":
return "Windows";
case "linux":
return "Linux";
case undefined:
return "Unknown";
default:
return os;
}
}
+6 -6
View File
@@ -12,6 +12,11 @@ export interface PopulatedNode extends Machine {
};
}
const GO_ZERO_TIMES = new Set(["0001-01-01 00:00:00", "0001-01-01T00:00:00Z"]);
export function isNoExpiry(expiry: string | null | undefined): boolean {
return expiry == null || GO_ZERO_TIMES.has(expiry);
}
export function mapNodes(
nodes: Machine[],
stats?: Record<string, HostInfo> | undefined,
@@ -35,12 +40,7 @@ export function mapNodes(
routes: Array.from(new Set(node.availableRoutes)),
hostInfo: stats?.[node.nodeKey],
customRouting,
expired:
node.expiry === "0001-01-01 00:00:00" ||
node.expiry === "0001-01-01T00:00:00Z" ||
node.expiry === null
? false
: new Date(node.expiry).getTime() < Date.now(),
expired: isNoExpiry(node.expiry) ? false : new Date(node.expiry!).getTime() < Date.now(),
};
});
}
+24 -24
View File
@@ -1,39 +1,39 @@
import { data } from 'react-router';
import { data } from "react-router";
export function send<T>(payload: T, init?: number | ResponseInit) {
return data(payload, init);
return data(payload, init);
}
export function send401<T>(payload: T) {
return data(payload, { status: 401 });
return data(payload, { status: 401 });
}
export function data400(message: string) {
return data(
{
success: false,
message,
},
{ status: 400 },
);
return data(
{
success: false,
message,
},
{ status: 400 },
);
}
export function data403(message: string) {
return data(
{
success: false,
message,
},
{ status: 403 },
);
return data(
{
success: false,
message,
},
{ status: 403 },
);
}
export function data404(message: string) {
return data(
{
success: false,
message,
},
{ status: 404 },
);
return data(
{
success: false,
message,
},
{ status: 404 },
);
}
+28 -28
View File
@@ -6,37 +6,37 @@
* - Over 1 month: "X months, Y days ago"
*/
export function formatTimeDelta(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const minutes = Math.floor(diffMs / (1000 * 60));
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const months = Math.floor(days / 30);
const minutes = Math.floor(diffMs / (1000 * 60));
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const months = Math.floor(days / 30);
if (minutes < 60) {
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
}
if (minutes < 60) {
return `${minutes} minute${minutes !== 1 ? "s" : ""} ago`;
}
if (hours < 24) {
const remainingMinutes = minutes % 60;
if (remainingMinutes === 0) {
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
}
return `${hours} hour${hours !== 1 ? 's' : ''}, ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''} ago`;
}
if (hours < 24) {
const remainingMinutes = minutes % 60;
if (remainingMinutes === 0) {
return `${hours} hour${hours !== 1 ? "s" : ""} ago`;
}
return `${hours} hour${hours !== 1 ? "s" : ""}, ${remainingMinutes} minute${remainingMinutes !== 1 ? "s" : ""} ago`;
}
if (days < 30) {
const remainingHours = hours % 24;
if (remainingHours === 0) {
return `${days} day${days !== 1 ? 's' : ''} ago`;
}
return `${days} day${days !== 1 ? 's' : ''}, ${remainingHours} hour${remainingHours !== 1 ? 's' : ''} ago`;
}
if (days < 30) {
const remainingHours = hours % 24;
if (remainingHours === 0) {
return `${days} day${days !== 1 ? "s" : ""} ago`;
}
return `${days} day${days !== 1 ? "s" : ""}, ${remainingHours} hour${remainingHours !== 1 ? "s" : ""} ago`;
}
const remainingDays = days % 30;
if (remainingDays === 0) {
return `${months} month${months !== 1 ? 's' : ''} ago`;
}
return `${months} month${months !== 1 ? 's' : ''}, ${remainingDays} day${remainingDays !== 1 ? 's' : ''} ago`;
const remainingDays = days % 30;
if (remainingDays === 0) {
return `${months} month${months !== 1 ? "s" : ""} ago`;
}
return `${months} month${months !== 1 ? "s" : ""}, ${remainingDays} day${remainingDays !== 1 ? "s" : ""} ago`;
}
+88 -57
View File
@@ -165,60 +165,91 @@ integration:
# OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience)
# oidc:
# Set to false to define OIDC config without enabling it.
# Useful for Helm charts or generating docs from config files.
# enabled: true
# The OIDC issuer URL
# issuer: "https://accounts.google.com"
# DEPRECATED: Use headscale.api_key instead.
# If set, this will be used as a fallback for headscale.api_key.
# headscale_api_key: "<your-headscale-api-key>"
# If your OIDC provider does not support discovery (does not have the URL at
# `/.well-known/openid-configuration`), you need to manually set endpoints.
# This also works to override endpoints if you so desire or if your OIDC
# discovery is missing certain endpoints (ie GitHub).
# For some typical providers, see https://headplane.net/features/sso.
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# The authentication method to use when communicating with the token endpoint.
# This is fully optional and Headplane will attempt to auto-detect the best
# method and fall back to `client_secret_basic` if unsure.
# token_endpoint_auth_method: "client_secret_post"
# The client ID for the OIDC client
# For the best experience please ensure this is *identical* to the client_id
# you are using for Headscale. because
# client_id: "your-client-id"
# The client secret for the OIDC client
# You may also provide `client_secret_path` instead to read a value from disk.
# See https://headplane.net/configuration/#sensitive-values
# client_secret: "<your-client-secret>"
# Whether to use PKCE when authenticating users. This is recommended as it
# adds an extra layer of security to the authentication process. Enabling this
# means your OIDC provider must support PKCE and it must be enabled on the
# client.
# use_pkce: true
# If you want to disable traditional login via Headscale API keys
# disable_api_key_login: false
# By default profile pictures are pulled from the OIDC provider when
# we go to fetch the userinfo endpoint. Optionally, this can be set to
# "oidc" or "gravatar" as of 0.6.1.
# profile_picture_source: "gravatar"
# The scopes to request when authenticating users. The default is below.
# scope: "openid email profile"
# Extra query parameters can be passed to the authorization endpoint
# by setting them here. This is useful for providers that require any kind
# of custom hinting.
# extra_params:
# prompt: "select_account" # Example: force account selection on Google
# # Set to false to define OIDC config without enabling it.
# # Useful for Helm charts or generating docs from config files.
# enabled: true
#
# # The OIDC issuer URL
# issuer: "https://accounts.google.com"
#
# # DEPRECATED: Use headscale.api_key instead.
# # If set, this will be used as a fallback for headscale.api_key.
# headscale_api_key: "<your-headscale-api-key>"
#
# # If your OIDC provider does not support discovery (does not have the URL at
# # `/.well-known/openid-configuration`), you need to manually set endpoints.
# # This also works to override endpoints if you so desire or if your OIDC
# # discovery is missing certain endpoints (ie GitHub).
# # For some typical providers, see https://headplane.net/features/sso.
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
#
# # RP-initiated logout (https://openid.net/specs/openid-connect-rpinitiated-1_0.html).
# # When true, /logout redirects the user to the IdP's end_session_endpoint
# # (auto-discovered or set manually below) so the upstream session is ended too.
# #
# # Disabled by default: the `post_logout_redirect_uri` MUST be pre-registered
# # in your OIDC client configuration on the IdP. If it isn't, users will land
# # on the provider's error page after logout.
# use_end_session: false
#
# # Optional. Override the auto-discovered end_session_endpoint, or supply one
# # if your provider does not advertise it via discovery.
# end_session_endpoint: ""
#
# # Where the identity provider should redirect after RP-initiated logout.
# # Most providers (Keycloak, Auth0, etc.) require this URL to be pre-registered
# # in the OIDC client configuration. If unset, Headplane defaults to its own
# # `<server.base_url>/admin/login?s=logout` page.
# post_logout_redirect_uri: ""
#
# # The authentication method to use when communicating with the token endpoint.
# # This is fully optional and Headplane will attempt to auto-detect the best
# # method and fall back to `client_secret_basic` if unsure.
# token_endpoint_auth_method: "client_secret_post"
#
# # The client ID for the OIDC client
# # For the best experience please ensure this is *identical* to the client_id
# # you are using for Headscale.
# client_id: "your-client-id"
#
# # The client secret for the OIDC client
# # You may also provide `client_secret_path` instead to read a value from disk.
# # See https://headplane.net/configuration/#sensitive-values
# client_secret: "<your-client-secret>"
#
# # Whether to use PKCE when authenticating users. This is recommended as it
# # adds an extra layer of security to the authentication process. Enabling
# # this means your OIDC provider must support PKCE and it must be enabled on
# # the client.
# use_pkce: true
#
# # If you want to disable traditional login via Headscale API keys
# disable_api_key_login: false
#
# # By default profile pictures are pulled from the OIDC provider when
# # we go to fetch the userinfo endpoint. Optionally, this can be set to
# # "oidc" or "gravatar" as of 0.6.1.
# profile_picture_source: "gravatar"
#
# # The scopes to request when authenticating users. The default is below.
# scope: "openid email profile"
#
# # Optional fallback claims to use when your provider does not return a standard
# # OIDC `sub` claim. Headplane always checks `sub` first, then each claim here
# # in order. For Feishu/Lark, `["open_id", "email"]` is a reasonable fallback.
# subject_claims:
# - "open_id"
# - "email"
#
# # Allow ID token verification with legacy RSA keys smaller than 2048 bits.
# # This is disabled by default because it lowers token verification security and
# # should only be used as a temporary compatibility workaround.
# allow_weak_rsa_keys: false
#
# # Extra query parameters can be passed to the authorization endpoint
# # by setting them here. This is useful for providers that require any kind
# # of custom hinting.
# extra_params:
# prompt: "select_account" # Example: force account selection on Google
+5
View File
@@ -1,6 +1,11 @@
import { defineConfig } from "vitepress";
export default defineConfig({
vite: {
define: {
__HEADPLANE_BETA_DOCS__: JSON.stringify(process.env.HEADPLANE_BETA_DOCS === "true"),
},
},
title: "Headplane",
description: "The missing dashboard for Headscale",
cleanUrls: true,
+31 -6
View File
@@ -1,17 +1,42 @@
html.dark .light-only {
display: none !important;
display: none !important;
}
html:not(.dark) .dark-only {
display: none !important;
display: none !important;
}
figure {
padding: 1em;
padding: 1em;
}
figcaption {
text-align: center;
font-size: 0.9em;
margin-top: 0.5em;
text-align: center;
font-size: 0.9em;
margin-top: 0.5em;
}
:root {
--vp-layout-top-height: 36px;
}
.beta-banner {
background-color: var(--vp-c-bg);
background-image: linear-gradient(var(--vp-c-warning-soft), var(--vp-c-warning-soft));
color: var(--vp-c-warning-1);
text-align: center;
padding: 8px 16px;
font-size: 14px;
font-weight: 500;
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
}
.beta-banner a {
color: var(--vp-c-warning-1);
text-decoration: underline;
margin-left: 8px;
}
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="vitepress/client" />
declare const __HEADPLANE_BETA_DOCS__: boolean;
+35 -3
View File
@@ -1,4 +1,36 @@
import DefaultTheme from 'vitepress/theme';
import './custom.css';
import type { Theme } from "vitepress";
import DefaultTheme from "vitepress/theme";
import { h } from "vue";
export default DefaultTheme;
import "./custom.css";
const beta = __HEADPLANE_BETA_DOCS__ ?? false;
export default {
extends: DefaultTheme,
Layout() {
return h(DefaultTheme.Layout, null, {
"layout-top": () =>
beta
? h(
"div",
{
class: "beta-banner",
},
[
"You are currently viewing the ",
h("strong", "beta"),
" documentation for Headplane. ",
h(
"a",
{
href: "https://headplane.net",
},
"Go to the stable docs →",
),
],
)
: null,
});
},
} satisfies Theme;
+4
View File
@@ -4,12 +4,15 @@ description: Common issues and their solutions
---
# Common Issues and Their Solutions
This document outlines some common issues users may encounter while using Headplane, along with their solutions.
## Login does not work
::: tip
Headplane tries to detect misconfigurations and will surface a warning banner on
the login page if it detects any abnormalities. You may see a banner like this:
<figure>
<img class="dark-only" src="../assets/login-banner-dark.png" />
<img class="light-only" src="../assets/login-banner-light.png" />
@@ -21,5 +24,6 @@ If you attempt to log in to Headplane but nothing happens, it may be due to a
misconfiguration of the server cookie settings. In your Headplane configuration,
ensure that `server.cookie_secure` is set appropriately based on how you are
accessing Headplane:
- Serving over HTTPS: `cookie_secure` should be enabled (`true`).
- Serving over HTTP: `cookie_secure` should be disabled (`false`).
+88 -5
View File
@@ -70,6 +70,8 @@ oidc:
# token_endpoint: ""
# userinfo_endpoint: ""
# scope: "openid email profile"
# subject_claims: ["open_id", "email"]
# allow_weak_rsa_keys: false
# extra_params:
# foo: "bar"
```
@@ -78,6 +80,40 @@ Headplane automatically discovers OIDC endpoints from your issuer's
`/.well-known/openid-configuration`. If your IdP does not support discovery,
you'll need to set the endpoints manually.
### Non-standard Subject Claims
Some providers do not return the standard OIDC `sub` claim in the ID token.
Headplane always uses `sub` first, but you can configure fallback claims with
`oidc.subject_claims`.
For Feishu/Lark, the recommended configuration is:
```yaml
oidc:
subject_claims: ["open_id", "email"]
```
This keeps identity matching stable by preferring `open_id` and only falling
back to `email` if needed.
### Legacy Weak RSA Signing Keys
Some legacy providers still sign ID tokens with RSA keys smaller than 2048
bits. Headplane rejects those keys by default.
If your provider cannot rotate to a stronger signing key yet, you can
explicitly enable the compatibility fallback:
```yaml
oidc:
allow_weak_rsa_keys: true
```
::: warning
This weakens ID token verification security and should only be used as a
temporary workaround while your provider rotates to a 2048-bit-or-larger key.
:::
### PKCE
::: warning
@@ -107,8 +143,9 @@ Headplane uses a two-step matching strategy:
1. **Subject match (primary)**: Headscale stores the IdP's `provider_id` for
each OIDC user (e.g. `https://idp.example.com/3d6f6e3f-...`). Headplane
extracts the last path segment and compares it to the `sub` claim from the
OIDC token. If they match, the user is linked.
extracts the last path segment and compares it to the resolved OIDC subject.
The resolved subject uses `sub` first, then falls back to any configured
`oidc.subject_claims`. If they match, the user is linked.
2. **Email match (fallback)**: If the subject doesn't match, Headplane falls
back to comparing the user's email address from the OIDC `userinfo` endpoint
@@ -198,6 +235,52 @@ When a new OIDC user signs in for the first time, they go through a brief
onboarding flow that helps them connect their first device to the Tailnet. This
flow can be skipped. Once completed, users are taken to the main dashboard.
## Single Logout (RP-Initiated Logout)
Headplane supports
[OpenID Connect RP-Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html).
When enabled, clicking "Log Out" in the UI from an OIDC-backed session will:
1. Destroy the local Headplane session.
2. Redirect the browser to the identity provider's `end_session_endpoint`.
3. Pass along the original `id_token` as `id_token_hint`, plus a
`post_logout_redirect_uri` so the IdP can return the user to Headplane after
it has cleared its own session.
### Configuration
This feature is **disabled by default** because the `post_logout_redirect_uri`
must be pre-registered in your OIDC client on the IdP. Enabling it without that
registration will land users on the provider's error page after logout.
To enable it, set `oidc.use_end_session: true`:
```yaml
oidc:
# Required: opt in to RP-initiated logout
use_end_session: true
# Optional: override the auto-discovered end_session_endpoint, or set it
# manually if your provider does not expose it via discovery.
# end_session_endpoint: "https://idp.example.com/realms/main/protocol/openid-connect/logout"
# Optional. Defaults to `<server.base_url>/admin/login?s=logout`.
# post_logout_redirect_uri: "https://headplane.example.com/admin/login?s=logout"
```
If your provider exposes `end_session_endpoint` in its discovery document
(Keycloak, Authentik, Auth0, Azure AD, …) Headplane picks it up automatically
once `use_end_session` is `true`.
::: tip
Make sure the redirect URI you supply (or the default one Headplane builds) is
listed under the post-logout / valid redirect URIs in your IdP's client
configuration, otherwise the provider will refuse to redirect back.
:::
When `use_end_session` is `false` (the default), Headplane simply destroys its
own session and returns the user to the login page.
## Troubleshooting
### Common Issues
@@ -217,9 +300,9 @@ flow can be skipped. Once completed, users are taken to the main dashboard.
- **Invalid API Key**: The `headscale.api_key` may have expired. Generate
a new one with `headscale apikeys create --expiration 999d`.
- **Missing the `sub` claim**: Ensure your IdP includes the `sub` claim in the
ID token. This is required by the OIDC spec but some providers need explicit
configuration.
- **Missing the `sub` claim**: If your IdP omits `sub`, configure
`oidc.subject_claims` with a stable fallback such as `open_id`. Only use
`email` as a fallback when it is stable for your users.
- **Redirect URI Mismatch**: Ensure the redirect URI registered in your IdP
matches `{server.base_url}/admin/oidc/callback` exactly.
-1
View File
@@ -32,4 +32,3 @@ features:
details: "Manage settings hidden behind Headscale configuration such as DNS, networking, auth controls, etc."
icon: "📝"
---
+18 -13
View File
@@ -12,15 +12,16 @@ set up your configuration file and then pick the installation method that best
suits your needs.
## Configuration
Headplane requires a configuration file to operate. A
[sample file](https://github.com/tale/headplane/blob/main/config.example.yaml)
is available to use as a starting point. Some of the important fields include:
| Field | Description |
|---------------------|--------------------------------------------------------|
| **`headscale.url`** | Point to your Headscale server (e.g., `http://headscale.example.com` or `http://headscale:8080` in Docker). |
| **`server.cookie_secret`** | Used to encrypt cookies. You can generate a random string using a command like `openssl rand -base64 24`. |
| **`server.data_path`** | Just a path to keep in mind, especially if you're using Docker. |
| Field | Description |
| -------------------------- | ----------------------------------------------------------------------------------------------------------- |
| **`headscale.url`** | Point to your Headscale server (e.g., `http://headscale.example.com` or `http://headscale:8080` in Docker). |
| **`server.cookie_secret`** | Used to encrypt cookies. You can generate a random string using a command like `openssl rand -base64 24`. |
| **`server.data_path`** | Just a path to keep in mind, especially if you're using Docker. |
The configuration file is rather complicated and has many more options. Refer to
the [Configuration](../configuration/index.md) guide for a detailed explanation of all
@@ -28,24 +29,28 @@ the available options, as well as guidance on securely setting up your values
through secret path options and environment variables.
## Deployment Methods
Headplane can be deployed in several different ways, each with its own set of
advantages and trade-offs. Choose the method that best fits your needs:
### [Docker](./docker.md): Fast and easy deployment using Docker
- Recommended for most users due to its simplicity and ease of use.
- Allows for advanced features like network management and remote web SSH.
- Requires Docker and Docker Compose to be installed.
- Recommended for most users due to its simplicity and ease of use.
- Allows for advanced features like network management and remote web SSH.
- Requires Docker and Docker Compose to be installed.
---
### [Native Mode](./native-mode.md): Direct installation on a server
- Suitable for users who prefer not to use Docker.
- Allows for advanced features like network management and remote web SSH.
- Requires manual setup of dependencies and environment.
- Suitable for users who prefer not to use Docker.
- Allows for advanced features like network management and remote web SSH.
- Requires manual setup of dependencies and environment.
---
### [Limited Mode](./limited-mode.md): Quick and easy deployment with minimal features
- Ideal for testing or simple environments and not intended for production use.
- Lacks any advanced functionality or integrations such as network management
- Ideal for testing or simple environments and not intended for production use.
- Lacks any advanced functionality or integrations such as network management
or remote web SSH.
+9 -6
View File
@@ -12,16 +12,18 @@ deployment. Limited mode lacks advanced features such as network management,
remote web SSH, and more.
:::
Limited Mode is good for users who want to test out the *basic* functionality
Limited Mode is good for users who want to test out the _basic_ functionality
provided by Headplane. It only interacts with the Headplane API and lacks all
advanced features, making it suitable for local testing and development.
## Prerequisites
- Docker (and optionally Docker Compose)
- Headscale version 0.26.0 or later installed and running
- A [completed configuration file](/index.md#configuration) for Headplane.
- A [completed configuration file](/index.md#configuration) for Headplane.
## Installation
::: tip
If you want to test Limited Mode without Docker, you can follow the
[Native Mode](./native-mode.md) installation guide and simply avoid setting
@@ -29,6 +31,7 @@ up any of the advanced features.
:::
Running Headplane in Limited Mode is as simple as running 1 command:
```bash
docker run -d \
-p 3000:3000 \
@@ -44,6 +47,7 @@ storage location for Headplane to store its own data. You can also change the
port mapping if you want to run it on a different port.
### Optional: Docker Compose
If you prefer using Docker Compose, here is a minimal example of a
`compose.yaml` file that runs Headplane in Limited Mode:
@@ -54,10 +58,10 @@ services:
container_name: headplane
restart: unless-stopped
ports:
- '3000:3000'
- "3000:3000"
volumes:
- '/path/to/your/config.yaml:/etc/headplane/config.yaml'
- '/path/to/data/storage:/var/lib/headplane'
- "/path/to/your/config.yaml:/etc/headplane/config.yaml"
- "/path/to/data/storage:/var/lib/headplane"
```
## Accessing Headplane
@@ -83,4 +87,3 @@ Limited Mode also technically supports
[Single Sign-On (SSO) authentication](../features/sso.md), but some parts of it
may not work as expected. For a full-featured experience with SSO, please use
one of the other installation methods.
+12
View File
@@ -0,0 +1,12 @@
{
"include": [".vitepress/**/*"],
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"target": "ES2022",
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"noEmit": true,
"strict": true,
"skipLibCheck": true
}
}
+6 -6
View File
@@ -1,8 +1,8 @@
import { defineConfig } from 'drizzle-kit';
import { defineConfig } from "drizzle-kit";
export default defineConfig({
dialect: 'sqlite',
schema: './app/server/db/schema.ts',
dbCredentials: {
url: 'file:test/hp_persist.db',
},
dialect: "sqlite",
schema: "./app/server/db/schema.ts",
dbCredentials: {
url: "file:test/hp_persist.db",
},
});
@@ -0,0 +1 @@
ALTER TABLE `auth_sessions` ADD `oidc_id_token` text;
@@ -0,0 +1,276 @@
{
"version": "7",
"dialect": "sqlite",
"id": "f0bdd789-6848-40b3-a8b6-99b4d1f6ef07",
"prevIds": ["dd3ce0c0-106f-4628-8c36-bdf9747e5f41"],
"ddl": [
{
"name": "auth_sessions",
"entityType": "tables"
},
{
"name": "host_info",
"entityType": "tables"
},
{
"name": "users",
"entityType": "tables"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "kind",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "user_id",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "api_key_hash",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "api_key_display",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "oidc_id_token",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "expires_at",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "auth_sessions"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "host_id",
"entityType": "columns",
"table": "host_info"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "payload",
"entityType": "columns",
"table": "host_info"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "host_info"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "id",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": null,
"generated": null,
"name": "sub",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "name",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "email",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "picture",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": true,
"autoincrement": false,
"default": "'member'",
"generated": null,
"name": "role",
"entityType": "columns",
"table": "users"
},
{
"type": "text",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "headscale_user_id",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "created_at",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "updated_at",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "last_login_at",
"entityType": "columns",
"table": "users"
},
{
"type": "integer",
"notNull": true,
"autoincrement": false,
"default": "0",
"generated": null,
"name": "caps",
"entityType": "columns",
"table": "users"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "auth_sessions_pk",
"table": "auth_sessions",
"entityType": "pks"
},
{
"columns": ["host_id"],
"nameExplicit": false,
"name": "host_info_pk",
"table": "host_info",
"entityType": "pks"
},
{
"columns": ["id"],
"nameExplicit": false,
"name": "users_pk",
"table": "users",
"entityType": "pks"
},
{
"columns": ["sub"],
"nameExplicit": false,
"name": "users_sub_unique",
"entityType": "uniques",
"table": "users"
},
{
"columns": ["headscale_user_id"],
"nameExplicit": false,
"name": "users_headscale_user_id_unique",
"entityType": "uniques",
"table": "users"
}
],
"renames": []
}
Generated
+3 -3
View File
@@ -40,11 +40,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1775126147,
"narHash": "sha256-J0dZU4atgcfo4QvM9D92uQ0Oe1eLTxBVXjJzdEMQpD0=",
"lastModified": 1776949667,
"narHash": "sha256-GMSVw35Q+294GlrTUKlx087E31z7KurReQ1YHSKp5iw=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "8d8c1fa5b412c223ffa47410867813290cdedfef",
"rev": "01fbdeef22b76df85ea168fbfe1bfd9e63681b30",
"type": "github"
},
"original": {
+20 -1
View File
@@ -133,11 +133,30 @@ func (s *TSAgent) FetchAllHostInfo(ctx context.Context) (map[string]json.RawMess
return
}
data, err := json.Marshal(whois.Node.Hostinfo)
// Merge hostinfo with connection status from PeerStatus
var merged map[string]any
raw, err := json.Marshal(whois.Node.Hostinfo)
if err != nil {
log.Debug("Failed to marshal hostinfo for %s (%s): %s", nodeID, ip, err)
return
}
if err := json.Unmarshal(raw, &merged); err != nil {
log.Debug("Failed to unmarshal hostinfo for %s (%s): %s", nodeID, ip, err)
return
}
endpoints := make([]string, len(whois.Node.Endpoints))
for i, ep := range whois.Node.Endpoints {
endpoints[i] = ep.String()
}
merged["Endpoints"] = endpoints
merged["HomeDERP"] = whois.Node.HomeDERP
data, err := json.Marshal(merged)
if err != nil {
log.Debug("Failed to marshal merged info for %s (%s): %s", nodeID, ip, err)
return
}
mu.Lock()
result[nodeID] = json.RawMessage(data)
+23 -23
View File
@@ -1,34 +1,34 @@
import fs from "node:fs";
function renderOptions(options) {
const blocks = Object.keys(options).map((key) => {
const opt = options[key];
const name = key.split(".").slice(2).join(".");
const lines = [];
lines.push(`## ${name}`);
lines.push(`*Description:* ${opt.description}\n`);
lines.push(`*Type:* ${opt.type}\n`);
if (opt.default) {
lines.push(`*Default:* \`${opt.default.text}\`\n`);
}
if (opt.example) {
lines.push(`*Example:* \`${opt.example.text}\`\n`);
}
return lines.join("\n");
});
const blocks = Object.keys(options).map((key) => {
const opt = options[key];
const name = key.split(".").slice(2).join(".");
const lines = [];
lines.push(`## ${name}`);
lines.push(`*Description:* ${opt.description}\n`);
lines.push(`*Type:* ${opt.type}\n`);
if (opt.default) {
lines.push(`*Default:* \`${opt.default.text}\`\n`);
}
if (opt.example) {
lines.push(`*Example:* \`${opt.example.text}\`\n`);
}
return lines.join("\n");
});
return [
`# NixOS module options
return [
`# NixOS module options
|
|All options must be under \`services.headplane\`.
|
|For example: \`settings.headscale.config_path\` becomes \`services.headplane.settings.headscale.config_path\`.`
.split("|")
.map((s) => s.replace(/\n\s+/g, ""))
.join("\n"),
]
.concat(blocks)
.join("\n\n");
.split("|")
.map((s) => s.replace(/\n\s+/g, ""))
.join("\n"),
]
.concat(blocks)
.join("\n\n");
}
const filename = process.argv[2];
+1 -1
View File
@@ -33,7 +33,7 @@ in
inherit (finalAttrs) pname version src;
fetcherVersion = 3;
pnpm = pnpm_10;
hash = "sha256-oJt5ysYXytwNR8Yx5nkEN++YQNaf9EO4fP4CPrChUPo=";
hash = "sha256-NGIeboj/2kXuWsmTVl1fv4LgU1VYRdO+qSnNLVuneC8=";
};
buildPhase = ''
+34 -39
View File
@@ -1,6 +1,6 @@
{
"name": "headplane",
"version": "0.7.0-beta.2",
"version": "0.7.0-beta.3",
"private": true,
"type": "module",
"sideEffects": false,
@@ -20,7 +20,9 @@
"format": "oxfmt"
},
"dependencies": {
"@base-ui/react": "^1.2.0",
"@base-ui/react": "^1.3.0",
"@codemirror/language": "^6.12.3",
"@codemirror/view": "^6.41.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/modifiers": "^7.0.0",
"@dnd-kit/sortable": "^8.0.0",
@@ -28,57 +30,53 @@
"@fontsource-variable/inter": "^5.2.8",
"@iconify/react": "^6.0.2",
"@kubernetes/client-node": "^1.4.0",
"@react-router/node": "^7.13.1",
"@readme/openapi-parser": "^5.5.0",
"@uiw/codemirror-theme-github": "4.25.1",
"@uiw/codemirror-theme-xcode": "4.25.5",
"@uiw/react-codemirror": "4.25.5",
"arktype": "^2.1.29",
"@lezer/highlight": "^1.2.3",
"@react-router/node": "^7.14.0",
"@readme/openapi-parser": "^6.0.1",
"@uiw/react-codemirror": "4.25.9",
"arktype": "^2.2.0",
"clsx": "^2.1.1",
"drizzle-orm": "1.0.0-beta.16-c2458b2",
"isbot": "5.1.35",
"jose": "6.2.1",
"drizzle-orm": "1.0.0-beta.21",
"isbot": "5.1.37",
"jose": "6.2.2",
"js-yaml": "^4.1.1",
"lucide-react": "^0.575.0",
"lucide-react": "^1.8.0",
"mime": "^4.1.0",
"openapi-types": "^12.1.3",
"react": "19.2.4",
"react-codemirror-merge": "4.25.5",
"react-dom": "19.2.4",
"react": "19.2.5",
"react-codemirror-merge": "4.25.9",
"react-dom": "19.2.5",
"react-error-boundary": "^6.1.1",
"react-router": "^7.13.1",
"react-router-dom": "^7.13.1",
"react-router-hono-server": "2.25.0",
"remix-utils": "^9.0.1",
"react-router": "^7.14.0",
"react-router-dom": "^7.14.0",
"restty": "^0.1.35",
"tailwind-merge": "3.5.0",
"ulidx": "2.4.1",
"undici": "7.22.0",
"yaml": "2.8.2"
"undici": "8.0.2",
"yaml": "2.8.3"
},
"devDependencies": {
"@react-router/dev": "^7.13.1",
"@react-router/dev": "^7.14.0",
"@shopify/lang-jsonc": "^1.0.1",
"@tailwindcss/vite": "^4.2.1",
"@tailwindcss/vite": "^4.2.2",
"@types/js-yaml": "^4.0.9",
"@types/node": "^25.3.1",
"@types/node": "^25.6.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@typescript/native-preview": "7.0.0-dev.20260225.1",
"drizzle-kit": "1.0.0-beta.16-c2458b2",
"lefthook": "^2.1.1",
"oxfmt": "^0.35.0",
"oxlint": "^1.50.0",
"oxlint-tsgolint": "^0.16.0",
"postcss": "^8.5.6",
"tailwindcss": "^4.2.1",
"@typescript/native-preview": "7.0.0-dev.20260410.1",
"drizzle-kit": "1.0.0-beta.21",
"lefthook": "^2.1.5",
"oxfmt": "^0.44.0",
"oxlint": "^1.59.0",
"oxlint-tsgolint": "^0.20.0",
"tailwindcss": "^4.2.2",
"tailwindcss-animate": "^1.0.7",
"testcontainers": "^11.12.0",
"testcontainers": "^11.14.0",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^8.0.0",
"typescript": "^6.0.2",
"vite": "^8.0.8",
"vitepress": "next",
"vitest": "^4.1.0"
"vitest": "^4.1.4"
},
"engines": {
"node": ">=24.2 <25",
@@ -106,9 +104,6 @@
"ssh2",
"utf-8-validate"
],
"patchedDependencies": {
"react-router-hono-server": "patches/react-router-hono-server.patch"
},
"ignoredBuiltDependencies": [
"better-sqlite3"
],
-20
View File
@@ -1,20 +0,0 @@
diff --git a/dist/adapters/node.js b/dist/adapters/node.js
index e081ab36a04ea100cf5da3fb5eb54c5174186f4d..35433810d05a829f2c824d63714e71bf5ed069a0 100644
--- a/dist/adapters/node.js
+++ b/dist/adapters/node.js
@@ -49,13 +49,13 @@ async function createHonoServer(options) {
}
await mergedOptions.beforeAll?.(app);
app.use(
- `/${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`,
+ `${import.meta.env.REACT_ROUTER_HONO_SERVER_BASENAME}${import.meta.env.REACT_ROUTER_HONO_SERVER_ASSETS_DIR}/*`,
cache(60 * 60 * 24 * 365),
// 1 year
serveStatic({ root: clientBuildPath, ...mergedOptions.serveStaticOptions?.clientAssets })
);
app.use(
- "*",
+ `${import.meta.env.REACT_ROUTER_HONO_SERVER_BASENAME}*`,
cache(60 * 60),
// 1 hour
serveStatic({ root: PRODUCTION ? clientBuildPath : "./public", ...mergedOptions.serveStaticOptions?.publicAssets })
+1183 -1481
View File
File diff suppressed because it is too large Load Diff
+203
View File
@@ -0,0 +1,203 @@
// MARK: HTTP Runtime
//
// Bare Node http(s) runtime that hosts a `RequestListener` (typically the
// React Router request listener from `@react-router/node`) and serves
// static assets out of a directory.
import { createReadStream } from "node:fs";
import { stat } from "node:fs/promises";
import {
type IncomingMessage,
type RequestListener,
type Server,
createServer as createHttpServer,
} from "node:http";
import type { ServerResponse } from "node:http";
import {
type ServerOptions as HttpsServerOptions,
createServer as createHttpsServer,
} from "node:https";
import { extname, normalize, resolve, sep } from "node:path";
import mime from "mime";
export interface Logger {
info: (msg: string, ...args: unknown[]) => void;
error: (msg: string, ...args: unknown[]) => void;
}
// TODO: Replace with Pino!
const defaultLogger: Logger = {
info: (msg, ...args) => console.log(`[runtime] ${msg}`, ...args),
error: (msg, ...args) => console.error(`[runtime] ${msg}`, ...args),
};
export interface StaticOptions {
root: string;
basename: string;
assetsDir: string;
immutableAssets: boolean;
}
/**
* Returns a static-file middleware. The callback resolves to `true` when
* the request was served, `false` when the caller should fall through to
* the next handler (e.g. the React Router request listener).
*/
function createStaticHandler(opts: StaticOptions) {
const root = resolve(opts.root);
const prefix = opts.basename.endsWith("/") ? opts.basename : `${opts.basename}/`;
const assetsPrefix = `${prefix}${opts.assetsDir}/`;
return async function serveStatic(req: IncomingMessage, res: ServerResponse) {
if (req.method !== "GET" && req.method !== "HEAD") return false;
if (!req.url) return false;
let pathname: string;
try {
pathname = decodeURIComponent(new URL(req.url, "http://localhost").pathname);
} catch {
return false;
}
if (!pathname.startsWith(prefix)) return false;
const rel = pathname.slice(prefix.length);
if (!rel || rel.endsWith("/")) return false;
// Resolve and confine to root to prevent path traversal.
const file = resolve(root, normalize(rel));
if (file !== root && !file.startsWith(root + sep)) return false;
let st;
try {
st = await stat(file);
} catch {
return false;
}
if (!st.isFile()) return false;
const isAsset = pathname.startsWith(assetsPrefix);
res.setHeader(
"Cache-Control",
isAsset && opts.immutableAssets
? "public, max-age=31536000, immutable"
: "public, max-age=3600",
);
const mimeType = mime.getType(extname(file)) ?? "application/octet-stream";
res.setHeader("Content-Type", mimeType);
res.setHeader("Content-Length", String(st.size));
res.setHeader("Last-Modified", st.mtime.toUTCString());
res.statusCode = 200;
if (req.method === "HEAD") {
res.end();
return true;
}
await new Promise<void>((resolve_, reject) => {
const stream = createReadStream(file);
stream.on("error", reject);
stream.on("end", () => resolve_());
stream.pipe(res);
});
return true;
};
}
export interface ListenerOptions {
basename: string;
staticRoot?: string;
assetsDir?: string;
immutableAssets?: boolean;
requestListener: RequestListener;
logger?: Logger;
}
/**
* Composes the full Node `RequestListener` chain:
* 1. `${basename}` → 302 to `${basename}/`
* 2. Static asset serving (optional)
* 3. Delegate to the downstream request listener
*/
export function composeListener(opts: ListenerOptions): RequestListener {
const log = opts.logger ?? defaultLogger;
const basename = opts.basename;
const serveStatic = opts.staticRoot
? createStaticHandler({
root: opts.staticRoot,
basename,
assetsDir: opts.assetsDir ?? "assets",
immutableAssets: opts.immutableAssets ?? true,
})
: null;
return (req, res) => {
if (req.url) {
try {
const url = new URL(req.url, "http://localhost");
if (url.pathname === basename) {
res.statusCode = 302;
res.setHeader("Location", `${basename}/${url.search}`);
res.end();
return;
}
} catch {}
}
if (!serveStatic) {
opts.requestListener(req, res);
return;
}
serveStatic(req, res)
.then((handled) => {
if (!handled) opts.requestListener(req, res);
})
.catch((err) => {
log.error("Static handler failed: %s", err);
if (!res.headersSent) {
res.statusCode = 500;
res.end("Internal Server Error");
} else {
res.destroy(err as Error);
}
});
};
}
export interface StartOptions {
host: string;
port: number;
listener: RequestListener;
tls?: HttpsServerOptions;
logger?: Logger;
}
/**
* Creates and starts an http(s) server. Wires up SIGINT/SIGTERM for
* graceful shutdown.
*/
export function startHttpServer(opts: StartOptions): Server {
const log = opts.logger ?? defaultLogger;
const server = opts.tls
? createHttpsServer(opts.tls, opts.listener)
: createHttpServer(opts.listener);
server.listen(opts.port, opts.host, () => {
const proto = opts.tls ? "https" : "http";
log.info("Listening on %s://%s:%s", proto, opts.host, opts.port);
});
const shutdown = (signal: string) => {
log.info("Received %s, shutting down...", signal);
server.close(() => process.exit(0));
// Force exit if connections don't drain in time.
setTimeout(() => process.exit(0), 5_000).unref();
};
process.once("SIGINT", () => shutdown("SIGINT"));
process.once("SIGTERM", () => shutdown("SIGTERM"));
return server;
}
+59
View File
@@ -0,0 +1,59 @@
// MARK: Vite Plugin
//
// In development we want `react-router dev` (Vite) to host the entire
// app, so we register a single connect-style middleware that:
// 1. loads the SSR entry through Vite's `ssrLoadModule` (HMR-aware),
// 2. dispatches every request through a `composeListener` chain
// (basename redirect → static asset fallback → app handler).
import type { RequestListener } from "node:http";
import type { Plugin } from "vite";
import { composeListener } from "./http";
export interface DevServerOptions {
entry: string;
basename: string;
publicDir: string;
}
export function headplaneDevServer(options: DevServerOptions): Plugin {
return {
name: "headplane:dev-server",
apply: "serve",
configureServer(server) {
// Lazy reference to the loaded entry; recomputed per request so
// Vite's HMR picks up changes via `ssrLoadModule`.
let appListener: RequestListener = (_req, res) => {
res.statusCode = 503;
res.end("Server entry not loaded yet");
};
const composed = composeListener({
basename: options.basename,
staticRoot: options.publicDir,
immutableAssets: false,
requestListener: (req, res) => appListener(req, res),
});
// Defer registration so our middleware runs AFTER Vite's
// transform/asset middlewares (those serve `/@vite/...`,
// `/node_modules/...`, module transforms, etc.).
return () => {
server.middlewares.use(async (req, res, next) => {
try {
const mod = (await server.ssrLoadModule(options.entry)) as {
default: RequestListener;
};
appListener = mod.default;
composed(req, res);
} catch (err) {
if (err instanceof Error) server.ssrFixStacktrace(err);
next(err);
}
});
};
},
};
}
+2 -2
View File
@@ -1,8 +1,8 @@
import type { OpenAPIV2 } from "openapi-types";
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { cwd } from "node:process";
import type { OpenAPIV2 } from "openapi-types";
import { request } from "undici";
import { hashOpenApiDocument } from "~/server/headscale/api/hasher";
+39 -44
View File
@@ -1,73 +1,68 @@
import hashes from '~/openapi-operation-hashes.json';
import {
createHeadscaleInterface,
type HeadscaleApiInterface,
} from '~/server/headscale/api';
import { type HeadscaleEnv, startHeadscale } from './start-headscale';
import { startTailscaleNode, TailscaleNodeEnv } from './start-tailscale';
import hashes from "~/openapi-operation-hashes.json";
import { createHeadscaleInterface, type HeadscaleApiInterface } from "~/server/headscale/api";
import { type HeadscaleEnv, startHeadscale } from "./start-headscale";
import { startTailscaleNode, TailscaleNodeEnv } from "./start-tailscale";
export type Version = keyof typeof hashes;
export const HS_VERSIONS = Object.keys(hashes) as Version[];
interface VersionStateEntry {
env: HeadscaleEnv;
tailscaleNode: TailscaleNodeEnv;
bootstrap: HeadscaleApiInterface;
env: HeadscaleEnv;
tailscaleNode: TailscaleNodeEnv;
bootstrap: HeadscaleApiInterface;
}
const versionState = new Map<Version, VersionStateEntry>();
async function ensureVersion(version: Version) {
if (versionState.has(version)) {
return versionState.get(version)!;
}
if (versionState.has(version)) {
return versionState.get(version)!;
}
const env = await startHeadscale(version);
const tailscaleNode = await startTailscaleNode(
version,
env.container.getMappedPort(8080),
);
const bootstrap = await createHeadscaleInterface(env.apiUrl);
const env = await startHeadscale(version);
const tailscaleNode = await startTailscaleNode(version, env.container.getMappedPort(8080));
const bootstrap = await createHeadscaleInterface(env.apiUrl);
const entry = { env, tailscaleNode, bootstrap };
versionState.set(version, entry);
return entry;
const entry = { env, tailscaleNode, bootstrap };
versionState.set(version, entry);
return entry;
}
export async function getBootstrapClient(version: Version) {
const { bootstrap } = await ensureVersion(version);
return bootstrap;
const { bootstrap } = await ensureVersion(version);
return bootstrap;
}
export async function getRuntimeClient(version: Version) {
const { env, bootstrap } = await ensureVersion(version);
return bootstrap.getRuntimeClient(env.apiKey);
const { env, bootstrap } = await ensureVersion(version);
return bootstrap.getRuntimeClient(env.apiKey);
}
export async function getIsAtLeast(version: Version) {
const { env, bootstrap } = await ensureVersion(version);
return bootstrap.clientHelpers.isAtleast;
const { env, bootstrap } = await ensureVersion(version);
return bootstrap.clientHelpers.isAtleast;
}
export async function getNode(version: Version) {
const { tailscaleNode } = await ensureVersion(version);
return {
authCode: tailscaleNode.authCode,
nodeName: tailscaleNode.nodeName,
};
const { tailscaleNode } = await ensureVersion(version);
return {
authCode: tailscaleNode.authCode,
nodeName: tailscaleNode.nodeName,
};
}
export async function stopAllVersions() {
for (const { env, tailscaleNode } of versionState.values()) {
await env.container.stop({
remove: true,
removeVolumes: true,
});
for (const { env, tailscaleNode } of versionState.values()) {
await env.container.stop({
remove: true,
removeVolumes: true,
});
await tailscaleNode.container.stop({
remove: true,
removeVolumes: true,
});
}
await tailscaleNode.container.stop({
remove: true,
removeVolumes: true,
});
}
versionState.clear();
versionState.clear();
}
+64 -67
View File
@@ -1,85 +1,82 @@
import { createInterface } from 'node:readline';
import tc from 'testcontainers';
import hashes from '~/openapi-operation-hashes.json';
import { createInterface } from "node:readline";
import tc from "testcontainers";
import hashes from "~/openapi-operation-hashes.json";
export type Version = keyof typeof hashes;
export interface TailscaleNodeEnv {
container: tc.StartedTestContainer;
authCode: string;
nodeName: string;
container: tc.StartedTestContainer;
authCode: string;
nodeName: string;
}
export async function startTailscaleNode(
version: Version,
headscalePort: number,
version: Version,
headscalePort: number,
): Promise<TailscaleNodeEnv> {
let resolveAuthCode!: (code: string) => void;
let rejectAuthCode!: (err: Error) => void;
let authCodeResolved = false;
let resolveAuthCode!: (code: string) => void;
let rejectAuthCode!: (err: Error) => void;
let authCodeResolved = false;
const authCodePromise = new Promise<string>((resolve, reject) => {
resolveAuthCode = resolve;
rejectAuthCode = reject;
});
const authCodePromise = new Promise<string>((resolve, reject) => {
resolveAuthCode = resolve;
rejectAuthCode = reject;
});
const nodeName = `test-node-${version.replace(/\./g, '-')}`;
const prefix = `http://localhost:8080/register/`;
const container = await new tc.GenericContainer('tailscale/tailscale:latest')
.withNetworkMode('host')
.withEnvironment({
TS_STATE_DIR: '/tailscale-state',
TS_EXTRA_ARGS: [
`--login-server=http://localhost:${headscalePort}`,
`--hostname=${nodeName}`,
'--accept-dns=false',
'--accept-routes=false',
].join(' '),
})
.withLogConsumer((stream) => {
const rl = createInterface({ input: stream });
const nodeName = `test-node-${version.replace(/\./g, "-")}`;
const prefix = `http://localhost:8080/register/`;
const container = await new tc.GenericContainer("tailscale/tailscale:latest")
.withNetworkMode("host")
.withEnvironment({
TS_STATE_DIR: "/tailscale-state",
TS_EXTRA_ARGS: [
`--login-server=http://localhost:${headscalePort}`,
`--hostname=${nodeName}`,
"--accept-dns=false",
"--accept-routes=false",
].join(" "),
})
.withLogConsumer((stream) => {
const rl = createInterface({ input: stream });
rl.on('line', (line: string) => {
if (authCodeResolved) return;
const idx = line.indexOf(prefix);
if (idx === -1) return;
rl.on("line", (line: string) => {
if (authCodeResolved) return;
const idx = line.indexOf(prefix);
if (idx === -1) return;
const after = line.slice(idx + prefix.length).trim();
if (!after) return;
const after = line.slice(idx + prefix.length).trim();
if (!after) return;
const token = after.split(/\s+/)[0];
if (!token) return;
const token = after.split(/\s+/)[0];
if (!token) return;
authCodeResolved = true;
resolveAuthCode(token);
rl.close();
});
authCodeResolved = true;
resolveAuthCode(token);
rl.close();
});
rl.on('close', () => {
if (!authCodeResolved) {
rejectAuthCode(
new Error(
'Tailscale container log stream closed before auth code was found',
),
);
}
});
})
.withWaitStrategy(tc.Wait.forLogMessage(prefix).withStartupTimeout(30_000))
.start();
rl.on("close", () => {
if (!authCodeResolved) {
rejectAuthCode(
new Error("Tailscale container log stream closed before auth code was found"),
);
}
});
})
.withWaitStrategy(tc.Wait.forLogMessage(prefix).withStartupTimeout(30_000))
.start();
const authCode = await Promise.race<string>([
authCodePromise,
new Promise((_, reject) =>
setTimeout(
() =>
reject(
new Error(`Timed out waiting for Tailscale auth URL on ${prefix}`),
),
25_000,
),
),
]);
const authCode = await Promise.race<string>([
authCodePromise,
new Promise((_, reject) =>
setTimeout(
() => reject(new Error(`Timed out waiting for Tailscale auth URL on ${prefix}`)),
25_000,
),
),
]);
return { container, authCode, nodeName };
return { container, authCode, nodeName };
}
+5 -5
View File
@@ -1,11 +1,11 @@
import { getBootstrapClient, HS_VERSIONS, stopAllVersions } from './env';
import { getBootstrapClient, HS_VERSIONS, stopAllVersions } from "./env";
export async function setup() {
for (const version of HS_VERSIONS) {
await getBootstrapClient(version);
}
for (const version of HS_VERSIONS) {
await getBootstrapClient(version);
}
}
export async function teardown() {
await stopAllVersions();
await stopAllVersions();
}
+1 -1
View File
@@ -194,7 +194,7 @@ describe("session round-trip", () => {
test("expired session throws", async () => {
const userId = await auth.findOrCreateUser("sub-1", { name: "Alice" });
const cookieHeader = await auth.createOidcSession(userId, { name: "Alice" }, -1);
const cookieHeader = await auth.createOidcSession(userId, { name: "Alice" }, { maxAge: -1 });
const cookieValue = cookieHeader.split(";")[0];
const request = new Request("http://localhost/test", {
+71
View File
@@ -112,6 +112,77 @@ describe("Configuration YAML file loading", () => {
expect(config.oidc?.enabled).toBe(false);
});
test("oidc.subject_claims can be configured from YAML", async () => {
const filePath = "/config/oidc-subject-claims.yaml";
writeYaml(filePath, {
headscale: { url: "http://localhost:8080" },
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
oidc: {
issuer: "https://accounts.google.com",
client_id: "my-client-id",
client_secret: "my-client-secret",
headscale_api_key: "my-api-key",
subject_claims: ["open_id", "email"],
},
});
const config = await loadConfig(filePath);
expect(config.oidc?.subject_claims).toEqual(["open_id", "email"]);
});
test("oidc.subject_claims are trimmed, deduplicated, and drop empty values", async () => {
const filePath = "/config/oidc-subject-claims-normalized.yaml";
writeYaml(filePath, {
headscale: { url: "http://localhost:8080" },
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
oidc: {
issuer: "https://accounts.google.com",
client_id: "my-client-id",
client_secret: "my-client-secret",
headscale_api_key: "my-api-key",
subject_claims: [" open_id ", "", "email", "open_id", " "],
},
});
const config = await loadConfig(filePath);
expect(config.oidc?.subject_claims).toEqual(["open_id", "email"]);
});
test("oidc.allow_weak_rsa_keys defaults to false", async () => {
const filePath = "/config/oidc-weak-rsa-default.yaml";
writeYaml(filePath, {
headscale: { url: "http://localhost:8080" },
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
oidc: {
issuer: "https://accounts.google.com",
client_id: "my-client-id",
client_secret: "my-client-secret",
headscale_api_key: "my-api-key",
},
});
const config = await loadConfig(filePath);
expect(config.oidc?.allow_weak_rsa_keys).toBe(false);
});
test("oidc.allow_weak_rsa_keys can be enabled from YAML", async () => {
const filePath = "/config/oidc-weak-rsa-enabled.yaml";
writeYaml(filePath, {
headscale: { url: "http://localhost:8080" },
server: { cookie_secret: "thirtytwo-character-cookiesecret" },
oidc: {
issuer: "https://accounts.google.com",
client_id: "my-client-id",
client_secret: "my-client-secret",
headscale_api_key: "my-api-key",
allow_weak_rsa_keys: true,
},
});
const config = await loadConfig(filePath);
expect(config.oidc?.allow_weak_rsa_keys).toBe(true);
});
test("partial oidc config with enabled field can be parsed", async () => {
const filePath = "/config/oidc-partial.yaml";
writeYaml(filePath, {
-1
View File
@@ -1,7 +1,6 @@
import { describe, expect, test } from "vitest";
import type { PartialHeadplaneConfigWithPaths } from "~/server/config/config-schema";
import { ConfigError } from "~/server/config/error";
import { loadConfigKeyPaths } from "~/server/config/load";

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