Compare commits

...

42 Commits

Author SHA1 Message Date
Aarnav Tale e09c5760af chore: random quick fixes 2025-12-04 04:11:52 -05:00
Aarnav Tale a824ea3dcf chore: spammy log cleanup 2025-12-04 03:37:48 -05:00
Aarnav Tale bf3fdd47cd chore: changelog 2025-12-04 00:49:49 -05:00
Aarnav Tale f6cdad535a chore: update to vite 8 beta 2025-12-04 00:48:59 -05:00
Aarnav Tale ba61656fb0 feat: cleanup oidc logic and surface errors better 2025-12-04 00:44:31 -05:00
Aarnav Tale d363ed5486 chore: add tests for nodes 2025-12-03 19:05:54 -05:00
Aarnav Tale 45de5f6833 fix: define log debug 2025-12-02 20:22:18 -05:00
Aarnav Tale afc5d5451a chore: nix hash omg 2025-12-01 02:57:39 -05:00
Aarnav Tale ef6563742c chore: update api versions 2025-12-01 02:48:40 -05:00
Aarnav Tale 86184e3420 feat: overhaul config loading 2025-12-01 02:46:22 -05:00
Aarnav Tale d3d7c7cc0e feat: better error handling for oidc api key
This also ships with a better error page
2025-11-28 17:54:52 -05:00
Aarnav Tale c0c4ecf631 Merge pull request #370 from llluiix/fix/update-docker-api-version 2025-11-22 09:43:06 -05:00
Luiz Fernando Oliveira dos Santos 5ea96db816 fix(docker): bump docker api version to v1.44 2025-11-21 19:02:16 -03:00
Aarnav Tale cc533f1b72 fix: shut up vitest 2025-11-20 18:16:53 -05:00
Aarnav Tale 3d9b6370bf chore: refactor unit tests 2025-11-18 02:11:53 -05:00
Aarnav Tale afd512138b fix: nix is the death of me 2025-11-16 18:35:17 -05:00
Aarnav Tale 08a251cc8c feat: add e2e integration tests and canonical versioning 2025-11-16 18:31:09 -05:00
Aarnav Tale 63d1e84ebe chore: update deps 2025-11-16 18:28:13 -05:00
Aarnav Tale 20f5342ea3 Merge pull request #367 from tale/update_flake_lock_action 2025-11-16 16:10:13 -05:00
Aarnav Tale db850942ef Merge pull request #362 from itzTheMeow/main 2025-11-16 16:09:29 -05:00
github-actions[bot] c9986ba466 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/e1ebeec86b771e9d387dd02d82ffdc77ac753abc?narHash=sha256-g/da4FzvckvbiZT075Sb1/YDNDr%2BtGQgh4N8i5ceYMg%3D' (2025-11-07)
  → 'github:nixos/nixpkgs/1d4c88323ac36805d09657d13a5273aea1b34f0c?narHash=sha256-esRhOS0APE6k40Hs/jjReXg%2Brx%2BJ5LkWw7cuWFKlwYA%3D' (2025-11-15)
2025-11-16 08:25:27 +00:00
Aarnav Tale d331a22951 Merge pull request #363 from alexfornuto/patch-1 2025-11-11 00:58:37 -05:00
Aarnav Tale 6be801e9bb Merge pull request #364 from tale/update_flake_lock_action 2025-11-11 00:56:09 -05:00
github-actions[bot] 299859e279 flake.lock: Update
Flake lock file updates:

• Updated input 'devshell':
    'github:numtide/devshell/7c9e793ebe66bcba8292989a68c0419b737a22a0?narHash=sha256-kWNaq6wQUbUMlPgw8Y%2B9/9wP0F8SHkjy24/mN3UAppg%3D' (2025-03-08)
  → 'github:numtide/devshell/07bacc9531f5f4df6657c0a02a806443685f384a?narHash=sha256-RXN%2Blcx4DEn3ZS%2BLqEJSUu/HH%2BdwGvy0syN7hTo/Chg%3D' (2025-11-07)
• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/a7fc11be66bdfb5cdde611ee5ce381c183da8386?narHash=sha256-QoJjGd4NstnyOG4mm4KXF%2BweBzA2AH/7gn1Pmpfcb0A%3D' (2025-10-31)
  → 'github:nixos/nixpkgs/e1ebeec86b771e9d387dd02d82ffdc77ac753abc?narHash=sha256-g/da4FzvckvbiZT075Sb1/YDNDr%2BtGQgh4N8i5ceYMg%3D' (2025-11-07)
2025-11-09 08:23:05 +00:00
Meow 5090733013 fix: change nodeList to existingTags 2025-11-09 01:19:59 -05:00
Alex Fornuto 7d77f2cb81 Update default dns_records_path in config.example.yaml
The example path looked for Headscale's `extra_records.json` file in `/var/lib/headplane`, not `/var/lib/headscale`.
2025-11-07 15:03:59 -06:00
Meow c3e07e4e10 feat: change node tag dialog to use Select 2025-11-06 19:49:55 -05:00
Aarnav Tale 1aa921aebe chore: update deps 2025-11-05 00:00:35 -05:00
Aarnav Tale 444b2325fb feat: fix acl api logic to work correctly 2025-11-04 23:51:36 -05:00
Aarnav Tale c84e9ca4a8 feat: update more primitives to use the new api 2025-11-04 23:06:56 -05:00
Aarnav Tale d68737e410 feat: switch machine apis to new runtime api client 2025-11-04 22:44:58 -05:00
Aarnav Tale a68aedc297 feat: create type-safe API object for Headscale 2025-11-04 22:40:57 -05:00
Aarnav Tale 1c0561edb8 Merge pull request #357 from tale/update_flake_lock_action 2025-11-04 13:07:55 -05:00
Aarnav Tale e883a7cd63 Merge pull request #358 from rhoriguchi/main 2025-11-03 09:13:22 -05:00
Ryan Horiguchi cc2112a805 feat: add missing nix options cookie_max_age and cookie_domain 2025-11-03 02:24:41 +01:00
Aarnav Tale afde3399e1 fix: docs build 2025-11-02 16:43:14 -05:00
Aarnav Tale b1c93512df feat: support custom cookie options 2025-11-02 14:04:38 -05:00
Aarnav Tale ce4be73faf feat: add a warning banner on the login page for misconfigured cookies 2025-11-02 13:54:45 -05:00
github-actions[bot] 192cce23b0 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/02f2cb8e0feb4596d20cc52fda73ccee960e3538?narHash=sha256-tH3wHnOJms%2BU4k/rK2Nn1RfBrhffX92jLP/2VndSn0w%3D' (2025-10-24)
  → 'github:nixos/nixpkgs/a7fc11be66bdfb5cdde611ee5ce381c183da8386?narHash=sha256-QoJjGd4NstnyOG4mm4KXF%2BweBzA2AH/7gn1Pmpfcb0A%3D' (2025-10-31)
2025-11-02 08:23:15 +00:00
Aarnav Tale 7d7e08ee54 chore: pin node version to lts in engines.node 2025-10-31 10:23:01 -04:00
Aarnav Tale 06a667b561 chore: simplify ci pipeline 2025-10-31 02:05:58 -04:00
Aarnav Tale 75c688fd00 Merge pull request #335 from tale/next 2025-10-31 00:06:09 -04:00
132 changed files with 7427 additions and 5034 deletions
+20 -26
View File
@@ -2,14 +2,12 @@ name: Build
on:
push:
paths-ignore:
- ".zed/**"
- "assets/**"
- "docs/**"
- "CHANGELOG.md"
- "README.md"
branches:
- "main"
- "next"
pull_request:
concurrency:
@@ -21,42 +19,38 @@ permissions:
contents: read # Read access to the repository
jobs:
native:
name: native
ci:
name: Build and Test
runs-on: ubuntu-latest
steps:
- name: Check out the repo
uses: actions/checkout@v4
- name: Setup Mise
uses: jdx/mise-action@v2
- name: Set caching paths
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
echo "GO_CACHE=$(go env GOCACHE)" >> $GITHUB_ENV
echo "GO_MODCACHE=$(go env GOMODCACHE)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
run_install: false
- name: Setup Go cache
uses: actions/cache@v4
- name: Setup Node.js
uses: actions/setup-node@v6
with:
path: |
${{ env.GO_CACHE }}
${{ env.GO_MODCACHE }}
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod', '**/go.sum') }}
node-version-file: package.json
cache: pnpm
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: CI pipeline
run: ./build.sh --skip-pnpm-prune
- name: Run unit tests
run: pnpm run test:unit
- name: Run integration tests
run: pnpm run test:integration
nix:
name: nix
runs-on: ubuntu-latest
+12 -1
View File
@@ -1,8 +1,19 @@
# 0.6.2 (Next)
- Added support for Headscale 0.27.0 and 0.27.1
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
- Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)).
- Implemented the ability to customize the build with a custom script (see `./build.sh --help` for more information).
- Attempt to warn against misconfigured cookie settings on the login page.
- Made `server.cookie_max_age` and `server.cookie_domain` configurable (closes [#348](https://github.com/tale/headplane/issues/348)).
- Re-worked the configuration loading system with several enhancements:
- It is now possible to skip a configuration file and only use environment variables (closes [#150](https://github.com/tale/headplane/issues/150)).
- Secret path loading has been reworked from the ground up to be more reliable (closes [#334](https://github.com/tale/headplane/issues/334)).
- Added better testing and validation for configuration loading
- Re-worked the OIDC integration to adhere to the correct standards and surface more errors to the user.
- Removed several unnecessarily verbose or spammy log messages.
- Updated the minimum Docker API used to support the latest Docker versions (via [#370](https://github.com/tale/headplane/pull/370)).
- Enhanced the node tag dialog to show a dropdown of assignable tags (via [#362](https://github.com/tale/headplane/pull/362)).
- Fixed an issue where the website favicon would not load correctly (closes [#323](https://github.com/tale/headplane/issues/323)).
---
# 0.6.1 (October 12, 2025)
+1 -1
View File
@@ -5,7 +5,7 @@ import toast from '~/utils/toast';
export interface CodeProps extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean;
children: string | string[];
children: string | string[] | number;
}
export default function Code({ isCopyable, children, className }: CodeProps) {
-89
View File
@@ -1,89 +0,0 @@
import { AlertCircle } from 'lucide-react';
import { isRouteErrorResponse, useRouteError } from 'react-router';
import ResponseError from '~/server/headscale/api-error';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
interface Props {
type?: 'full' | 'embedded';
}
export function getErrorMessage(error: Error | unknown): {
title: string;
message: string;
} {
if (error instanceof ResponseError) {
if (error.responseObject?.message) {
return {
title: 'Headscale Error',
message: String(error.responseObject.message),
};
}
return {
title: 'Headscale Error',
message: error.response,
};
}
if (!(error instanceof Error)) {
return {
title: 'Unknown Error',
message: String(error),
};
}
let rootError = error;
// Traverse the error chain to find the root cause
if (error.cause) {
rootError = error.cause as Error;
while (rootError.cause) {
rootError = rootError.cause as Error;
}
}
// If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
throw new Error('Unhandled AggregateError');
}
return {
title: 'Error',
message: rootError.message,
};
}
export function ErrorPopup({ type = 'full' }: Props) {
const error = useRouteError();
const routing = isRouteErrorResponse(error);
const { title, message } = getErrorMessage(error);
return (
<div
className={cn(
'flex items-center justify-center',
type === 'embedded'
? 'pointer-events-none mt-24'
: 'fixed inset-0 h-screen w-screen z-50',
)}
>
<Card>
<div className="flex items-center gap-4">
<AlertCircle className="w-8 h-8 text-red-500" />
<div className="flex justify-between items-center gap-2 w-full">
<Card.Title className="text-3xl mb-0">{title}</Card.Title>
{routing && <Code className="text-2xl">{`${error.status}`}</Code>}
</div>
</div>
<hr className="my-4 text-headplane-100 dark:text-headplane-800" />
<Card.Text
className={cn('py-4 text-lg', routing ? 'font-normal' : 'font-mono')}
>
{routing ? error.data : message}
</Card.Text>
</Card>
</div>
);
}
+14 -14
View File
@@ -50,11 +50,11 @@ function Select(props: SelectProps) {
<div className={cn('flex flex-col', props.className)}>
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
)}
htmlFor={id}
>
{props.label}
</label>
@@ -63,18 +63,18 @@ function Select(props: SelectProps) {
'flex rounded-xl focus:outline-hidden focus-within:ring-3',
'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800',
props.isInvalid && 'ring-red-400',
)}
>
<input
{...inputProps}
ref={inputRef}
id={id}
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
data-1p-ignore
id={id}
ref={inputRef}
/>
<button
{...buttonProps}
ref={buttonRef}
className={cn(
'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
@@ -82,6 +82,7 @@ function Select(props: SelectProps) {
? 'opacity-50 cursor-not-allowed'
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)}
ref={buttonRef}
>
<ChevronDown className="p-0.5" />
</button>
@@ -99,12 +100,12 @@ function Select(props: SelectProps) {
)}
{state.isOpen && (
<Popover
popoverRef={popoverRef}
triggerRef={inputRef}
state={state}
className="w-full max-w-xs"
isNonModal
placement="bottom start"
className="w-full max-w-xs"
popoverRef={popoverRef}
state={state}
triggerRef={inputRef}
>
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover>
@@ -114,23 +115,22 @@ function Select(props: SelectProps) {
}
interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef?: React.RefObject<HTMLUListElement | null>;
listBoxRef: React.RefObject<HTMLUListElement | null>;
state: ListState<object>;
}
function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props;
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null);
const { listBoxProps } = useListBox(props, state, ref);
const { listBoxProps } = useListBox(props, state, listBoxRef);
return (
<ul
{...listBoxProps}
ref={listBoxRef}
className="w-full max-h-72 overflow-auto outline-hidden pt-1"
ref={listBoxRef}
>
{[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} />
<Option item={item} key={item.key} state={state} />
))}
</ul>
);
@@ -154,7 +154,6 @@ function Option({ item, state }: OptionProps) {
return (
<li
{...optionProps}
ref={ref}
className={cn(
'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1',
@@ -164,6 +163,7 @@ function Option({ item, state }: OptionProps) {
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
isDisabled && 'text-headplane-300 dark:text-headplane-600',
)}
ref={ref}
>
{item.rendered}
{isSelected && <Check className="p-0.5" />}
+202
View File
@@ -0,0 +1,202 @@
import { AlertCircle } from 'lucide-react';
import { isRouteErrorResponse } from 'react-router';
import {
isApiError,
isConnectionError,
} from '~/server/headscale/api/error-client';
import cn from '~/utils/cn';
import Card from './Card';
import Code from './Code';
import Link from './Link';
export function getErrorMessage(error: Error | unknown): {
title: string;
jsxMessage: React.ReactNode;
} {
if (isRouteErrorResponse(error)) {
if (isApiError(error.data)) {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
title: 'Cannot connect to Headscale API',
jsxMessage: (
<Card.Text>
There was an error communicating with the Headscale API.
<br />
The server responded with a status code of{' '}
<strong>{statusCode}</strong>, indicating a server-side issue.
Please check the Headscale server status and try again later.
</Card.Text>
),
};
}
const authError =
error.data.statusCode === 401 || error.data.statusCode === 403;
return {
title: 'Invalid response from Headscale API',
jsxMessage: (
<>
<Card.Text className="leading-snug">
The Headscale API returned an unexpected response.
{authError ? (
<>
{' '}
The status code indicates an authentication error. Please
verify your API key and Headplane configuration.
</>
) : (
<>
{' '}
You may be using an unsupported version of Headscale or this
may be a bug.
</>
)}
</Card.Text>
<ul className="list-disc list-inside mt-2">
<li>
Request URL: <Code>{requestUrl}</Code>
</li>
<li>
Status Code:{' '}
<Code>
{/* @ts-expect-error */}
{data === null ? (
<>
{statusCode} {rawData}
</>
) : (
<>
{statusCode} {error.statusText}
</>
)}
</Code>
</li>
</ul>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
<code>{JSON.stringify(error.data, null, 2)}</code>
</pre>
</>
),
};
}
if (isConnectionError(error.data)) {
const { requestUrl, errorCode, errorMessage, extraData } = error.data;
return {
title: 'Cannot connect to Headscale API',
jsxMessage: (
<>
<Card.Text className="leading-snug">
Headplane was unable to reach the Headscale API. Please check your
network setup and configuration to ensure Headplane is able to
connect.
</Card.Text>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{requestUrl}
<br />
{errorCode}: {errorMessage}
{extraData != null && (
<>
<br />
<br />
<code>{JSON.stringify(extraData, null, 2)}</code>
</>
)}
</pre>
</>
),
};
}
return {
title: `Error ${error.status}`,
jsxMessage: (
<>
There was an error processing your request.
<br />
Status Code: <strong>{error.status}</strong>
<br />
Status Text: <strong>{error.statusText}</strong>
</>
),
};
}
if (!(error instanceof Error)) {
return {
title: 'Unexpected Error',
jsxMessage: (
<>
<Card.Text>
An unexpected error occurred which is most likely a bug. Please
consider reporting filing an issue on the{' '}
<Link
name="Headplane GitHub"
to="https://github.com/tale/headplane/issues"
>
Headplane GitHub
</Link>{' '}
repository with the details below.
</Card.Text>
<Card.Text className="text-lg font-semibold mt-4">
Error Details
</Card.Text>
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
<code>{JSON.stringify(error, null, 2)}</code>
</pre>
</>
),
};
}
// Traverse the error chain to find the root cause
let rootError = error;
console.log('error', error.cause != null);
if (error.cause != null) {
rootError = error.cause as Error;
while (rootError.cause != null) {
rootError = rootError.cause as Error;
console.log('setting rootError', rootError.message);
}
}
// TODO: If we are aggregate, concat into a single message
if (rootError instanceof AggregateError) {
throw new Error('AggregateError handling not implemented yet');
}
return {
title:
rootError.name.length > 0 && rootError.name !== 'Error'
? `Error: ${rootError.name}`
: 'Error',
jsxMessage: rootError.message,
};
}
interface ErrorBannerProps {
error: unknown;
className?: string;
}
export function ErrorBanner({ error, className }: ErrorBannerProps) {
const { title, jsxMessage } = getErrorMessage(error);
return (
<Card className={cn('w-screen', className)} variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>{title}</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{jsxMessage}
</Card>
);
}
-6
View File
@@ -2,12 +2,6 @@ import { StrictMode, startTransition } from 'react';
import { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom';
if (import.meta.env.DEV) {
import('react-scan').then(({ scan }) => {
scan({ enabled: true });
});
}
startTransition(() => {
hydrateRoot(
document,
+2 -1
View File
@@ -3,7 +3,8 @@ import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } from 'react-dom/server';
import { AppLoadContext, EntryContext, ServerRouter } from 'react-router';
import type { AppLoadContext, EntryContext } from 'react-router';
import { ServerRouter } from 'react-router';
export const streamTimeout = 5_000;
export default function handleRequest(
+22 -18
View File
@@ -1,27 +1,27 @@
import { type LoaderFunctionArgs, Outlet, redirect } from 'react-router';
import { ErrorPopup } from '~/components/Error';
import type { LoadContext } from '~/server';
import { Outlet, redirect } from 'react-router';
import { ErrorBanner } from '~/components/error-banner';
import { pruneEphemeralNodes } from '~/server/db/pruner';
import ResponseError from '~/server/headscale/api-error';
import { isDataUnauthorizedError } from '~/server/headscale/api/error-client';
import log from '~/utils/log';
import type { Route } from './+types/dashboard';
export async function loader({
request,
context,
...rest
}: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck();
export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
await pruneEphemeralNodes({ context, request, ...rest });
const api = context.hsApi.getRuntimeClient(session.api_key);
// We shouldn't session invalidate if Headscale is down
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled
// MARK: The session should stay valid if Headscale isn't healthy
const healthy = await api.isHealthy();
if (healthy) {
try {
await context.client.get('v1/apikey', session.api_key);
await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest });
} catch (error) {
if (error instanceof ResponseError) {
log.debug('api', 'API Key validation failed %o', error);
if (isDataUnauthorizedError(error)) {
log.warn(
'auth',
'Logging out %s due to expired API key',
session.user.name,
);
return redirect('/login', {
headers: {
'Set-Cookie': await context.sessions.destroySession(),
@@ -44,6 +44,10 @@ export default function Layout() {
);
}
export function ErrorBoundary() {
return <ErrorPopup type="embedded" />;
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="w-fit mx-auto overscroll-contain my-24">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
}
+12 -21
View File
@@ -1,30 +1,22 @@
import { eq } from 'drizzle-orm';
import { CircleCheckIcon } from 'lucide-react';
import {
LoaderFunctionArgs,
Outlet,
redirect,
useLoaderData,
} from 'react-router';
import { Outlet, redirect } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Footer from '~/components/Footer';
import Header from '~/components/Header';
import type { LoadContext } from '~/server';
import { users } from '~/server/db/schema';
import { Capabilities } from '~/server/web/roles';
import toast from '~/utils/toast';
import { Route } from './+types/shell';
// This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops?
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const session = await context.sessions.auth(request);
if (
context.oidc &&
typeof context.oidc === 'object' &&
session.user.subject !== 'unknown-non-oauth' &&
!request.url.endsWith('/onboarding')
) {
@@ -39,6 +31,7 @@ export async function loader({
}
}
const api = context.hsApi.getRuntimeClient(session.api_key);
const check = await context.sessions.check(request, Capabilities.ui_access);
return {
config: context.hs.c,
@@ -62,7 +55,7 @@ export async function loader({
),
},
onboarding: request.url.endsWith('/onboarding'),
healthy: await context.client.healthcheck(),
healthy: await api.isHealthy(),
};
} catch {
return redirect('/login', {
@@ -73,14 +66,12 @@ export async function loader({
}
}
export default function Shell() {
const data = useLoaderData<typeof loader>();
export default function Shell({ loaderData }: Route.ComponentProps) {
return (
<>
<Header {...data} />
<Header {...loaderData} />
{/* Always show the outlet if we are onboarding */}
{(data.onboarding ? true : data.uiAccess) ? (
{(loaderData.onboarding ? true : loaderData.uiAccess) ? (
<Outlet />
) : (
<Card className="mx-auto w-fit mt-24">
@@ -96,13 +87,13 @@ export default function Shell() {
className="flex text-md font-mono"
onPress={async () => {
await navigator.clipboard.writeText(
`tailscale up --login-server=${data.url}`,
`tailscale up --login-server=${loaderData.url}`,
);
toast('Copied to clipboard');
}}
>
tailscale up --login-server={data.url}
tailscale up --login-server={loaderData.url}
</Button>
<p className="text-xs mt-1 opacity-50 text-center">
Click this button to copy the command.
@@ -113,7 +104,7 @@ export default function Shell() {
</p>
</Card>
)}
<Footer {...data} />
<Footer {...loaderData} />
</>
);
}
+5
View File
@@ -0,0 +1,5 @@
{
"0.26.1": ["0.26.0", "0.26.1"],
"0.27.0": ["0.27.0"],
"0.27.1": ["0.27.1"]
}
+108
View File
@@ -0,0 +1,108 @@
{
"0.26.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.26.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.0": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "ac9ffcd6243a9784",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
},
"0.27.1": {
"GET /api/v1/apikey": "efe31b6dc980e158",
"POST /api/v1/apikey": "39953a96c1da5312",
"POST /api/v1/apikey/expire": "ca56add866802f17",
"DELETE /api/v1/apikey/{prefix}": "3f0125f7abe7abb1",
"POST /api/v1/debug/node": "204f9ae3f9f738c6",
"GET /api/v1/health": "5e447272e72b2e5f",
"GET /api/v1/node": "8bb18b8c7cfb4f20",
"POST /api/v1/node/backfillips": "6da4d1d3922a8001",
"POST /api/v1/node/register": "539f7cb3a84d43d4",
"GET /api/v1/node/{nodeId}": "8a7da3d24dc82c37",
"DELETE /api/v1/node/{nodeId}": "f832f33d84fd3724",
"POST /api/v1/node/{nodeId}/approve_routes": "e6c22e46ad44903d",
"POST /api/v1/node/{nodeId}/expire": "53efc8e2017c16ae",
"POST /api/v1/node/{nodeId}/rename/{newName}": "d355388ac934dc90",
"POST /api/v1/node/{nodeId}/tags": "b6a8296dcc2939b5",
"POST /api/v1/node/{nodeId}/user": "ae3a30b43ffd1922",
"GET /api/v1/policy": "d6c639be304cd3c0",
"PUT /api/v1/policy": "6cbe80bde771a388",
"GET /api/v1/preauthkey": "14db6a04f90d7a7e",
"POST /api/v1/preauthkey": "0b4308e049d4eb58",
"POST /api/v1/preauthkey/expire": "31f377a66d3a5c4f",
"GET /api/v1/user": "228831b58ccc5a17",
"POST /api/v1/user": "a4e1d889d7962da5",
"DELETE /api/v1/user/{id}": "3d553e4b74296884",
"POST /api/v1/user/{oldId}/rename/{newName}": "996c03ebf81576d7"
}
}
+8 -3
View File
@@ -9,12 +9,13 @@ import {
} from 'react-router';
import '@fontsource-variable/inter';
import { ExternalScripts } from 'remix-utils/external-scripts';
import { ErrorPopup } from '~/components/Error';
import ProgressBar from '~/components/ProgressBar';
import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url';
import { LiveDataProvider } from '~/utils/live-data';
import { useToastQueue } from '~/utils/toast';
import type { Route } from './+types/root';
import { ErrorBanner } from './components/error-banner';
export const meta: MetaFunction = () => [
{ title: 'Headplane' },
@@ -56,8 +57,12 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
);
}
export function ErrorBoundary() {
return <ErrorPopup />;
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return (
<div className="w-screen h-screen flex items-center justify-center p-4">
<ErrorBanner className="max-w-2xl" error={error} />
</div>
);
}
export default function App() {
+13 -18
View File
@@ -1,40 +1,35 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { data } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import { Capabilities } from '~/server/web/roles';
import { data400, data403 } from '~/utils/res';
import type { Route } from './+types/overview';
// We only check capabilities here and assume it is writable
// If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening.
export async function aclAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
export async function aclAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
Capabilities.write_policy,
);
if (!check) {
throw data403('You do not have permission to write to the ACL policy');
throw data('You do not have permission to write to the ACL policy', {
status: 403,
});
}
// Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData();
const policyData = formData.get('policy')?.toString();
if (!policyData) {
throw data400('Missing `policy` in the form data.');
throw data('Missing `policy` in the form data.', {
status: 400,
});
}
const api = context.hsApi.getRuntimeClient(session.api_key);
try {
const { policy, updatedAt } = await context.client.put<{
policy: string;
updatedAt: string;
}>('v1/policy', session.api_key, {
policy: policyData,
});
const { policy, updatedAt } = await api.setPolicy(policyData);
return data({
success: true,
error: undefined,
@@ -50,7 +45,7 @@ export async function aclAction({
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('update is disabled')) {
// This means the policy is not writable
throw data403('Policy is not writable');
throw data('Policy is not writable', { status: 403 });
}
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
+9 -14
View File
@@ -1,8 +1,7 @@
import { LoaderFunctionArgs } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { data } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import { Capabilities } from '~/server/web/roles';
import { data403 } from '~/utils/res';
import type { Route } from './+types/overview';
// The logic for deciding policy factors is very complicated because
// there are so many factors that need to be accounted for:
@@ -11,15 +10,13 @@ import { data403 } from '~/utils/res';
// 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available.
// TODO: Consider adding back file editing mode instead of database
export async function aclLoader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function aclLoader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_policy);
if (!check) {
throw data403('You do not have permission to read the ACL policy.');
throw data('You do not have permission to read the ACL policy.', {
status: 403,
});
}
const flags = {
@@ -30,11 +27,9 @@ export async function aclLoader({
};
// Try to load the ACL policy from the API.
const api = context.hsApi.getRuntimeClient(session.api_key);
try {
const { policy, updatedAt } = await context.client.get<{
policy: string;
updatedAt: string | null;
}>('v1/policy', session.api_key);
const { policy, updatedAt } = await api.getPolicy();
// Successfully loaded the policy, mark it as readable
// If `updatedAt` is null, it means the policy is in file mode.
+8 -8
View File
@@ -38,14 +38,14 @@ export function Editor(props: EditorProps) {
<ClientOnly fallback={<Fallback acl={props.value} />}>
{() => (
<CodeMirror
value={props.value}
editable={!props.isDisabled} // Allow editing unless disabled
readOnly={props.isDisabled} // Use readOnly if disabled
height="100%"
extensions={[shopify.jsonc()]}
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}
onChange={(value) => props.onChange(value)}
value={props.value}
/>
)}
</ClientOnly>
@@ -92,14 +92,14 @@ export function Differ(props: DifferProps) {
{() => (
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
<Merge.Original
extensions={[shopify.jsonc()]}
readOnly
value={props.left}
extensions={[shopify.jsonc()]}
/>
<Merge.Modified
extensions={[shopify.jsonc()]}
readOnly
value={props.right}
extensions={[shopify.jsonc()]}
/>
</Merge>
)}
+1 -1
View File
@@ -23,12 +23,12 @@ export default function Fallback({ acl }: Props) {
/>
</div>
<textarea
readOnly
className={cn(
'w-full h-editor font-mono resize-none text-sm',
'bg-headplane-50 dark:bg-headplane-950 opacity-60',
'pl-1 pt-1 leading-snug',
)}
readOnly
value={acl}
/>
</div>
+13 -24
View File
@@ -1,34 +1,23 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
ActionFunctionArgs,
LoaderFunctionArgs,
useFetcher,
useLoaderData,
useRevalidator,
} from 'react-router';
import { useFetcher, useRevalidator } from 'react-router';
import Button from '~/components/Button';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import type { LoadContext } from '~/server';
import toast from '~/utils/toast';
import type { Route } from './+types/overview';
import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client';
export async function loader(request: LoaderFunctionArgs<LoadContext>) {
return aclLoader(request);
}
export const loader = aclLoader;
export const action = aclAction;
export async function action(request: ActionFunctionArgs<LoadContext>) {
return aclAction(request);
}
export default function Page() {
// Access is a write check here, we already check read in aclLoader
const { access, writable, policy } = useLoaderData<typeof loader>();
export default function Page({
loaderData: { access, writable, policy },
}: Route.ComponentProps) {
const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator();
@@ -75,15 +64,15 @@ export default function Page() {
The ACL file is used to define the access control rules for your
network. You can find more information about the ACL file in the{' '}
<Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation"
to="https://tailscale.com/kb/1018/acls"
>
Tailscale ACL guide
</Link>{' '}
and the{' '}
<Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation"
to="https://headscale.net/stable/ref/acls/"
>
Headscale docs
</Link>
@@ -91,14 +80,14 @@ export default function Page() {
</p>
{fetcher.data?.error !== undefined ? (
<Notice
variant="error"
title={fetcher.data.error.split(':')[0] ?? 'Error'}
variant="error"
>
{fetcher.data.error.split(':').slice(1).join(': ') ??
'An unknown error occurred while trying to update the ACL policy.'}
</Notice>
) : undefined}
<Tabs label="ACL Editor" className="mb-4">
<Tabs className="mb-4" label="ACL Editor">
<Tabs.Item
key="edit"
title={
@@ -110,8 +99,8 @@ export default function Page() {
>
<Editor
isDisabled={disabled}
value={codePolicy}
onChange={setCodePolicy}
value={codePolicy}
/>
</Tabs.Item>
<Tabs.Item
@@ -145,7 +134,6 @@ export default function Page() {
</Tabs.Item>
</Tabs>
<Button
variant="heavy"
className="mr-2"
isDisabled={
disabled ||
@@ -158,6 +146,7 @@ export default function Page() {
formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' });
}}
variant="heavy"
>
Save
</Button>
+6 -12
View File
@@ -1,13 +1,9 @@
import { ActionFunctionArgs, data, redirect } from 'react-router';
import { LoadContext } from '~/server';
import ResponseError from '~/server/headscale/api-error';
import { Key } from '~/types';
import { data, redirect } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import log from '~/utils/log';
import type { Route } from './+types/page';
export async function loginAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
export async function loginAction({ request, context }: Route.LoaderArgs) {
const formData = await request.formData();
const apiKey = formData.has('api_key')
? String(formData.get('api_key'))
@@ -31,11 +27,9 @@ export async function loginAction({
throw data('Received an empty `api_key`', { status: 400 });
}
const api = context.hsApi.getRuntimeClient(apiKey);
try {
const { apiKeys } = await context.client.get<{ apiKeys: Key[] }>(
'v1/apikey',
apiKey,
);
const apiKeys = await api.getApiKeys();
// We don't need to check for 0 API keys because this request cannot
// be authenticated correctly without an API key
+123
View File
@@ -0,0 +1,123 @@
import { AlertCircle } from 'lucide-react';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Link from '~/components/Link';
import { OidcConnectorError } from '~/server/web/oidc-connector';
export function OidcConfigErrorNotice({
errors,
}: {
errors: OidcConnectorError[];
}) {
return (
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Authentication Error</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
<Card.Text className="text-sm">
The OpenID Connect (OIDC) Single Sign-On (SSO) configuration has issues:{' '}
<ul className="list-disc list-inside mt-2 mb-1">
{mapOidcErrorsToMessages(errors).map((code) => (
<li key={code.key}>{code.node}</li>
))}
</ul>{' '}
<Link
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#help"
>
Learn more
</Link>
</Card.Text>
</Card>
);
}
function mapOidcErrorsToMessages(errors: OidcConnectorError[]) {
const messages: {
key: string;
node: React.ReactNode;
}[] = [];
for (const error of errors) {
switch (error) {
case 'INVALID_API_KEY':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The provided API key for OIDC authentication is invalid. Ensure
that <Code>oidc.headscale_api_key</Code> is a valid API key.
</Card.Text>
),
});
break;
case 'MISSING_AUTHORIZATION_ENDPOINT':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured{' '}
<Code>authorization_endpoint</Code>. Ensure discovery URL or
manual configuration is correct.
</Card.Text>
),
});
break;
case 'MISSING_TOKEN_ENDPOINT':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured{' '}
<Code>token_endpoint</Code>. Ensure discovery URL or manual
configuration is correct.
</Card.Text>
),
});
break;
case 'MISSING_USERINFO_ENDPOINT':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provided does not have a configured{' '}
<Code>user_endpoint</Code>. Ensure discovery URL or manual
configuration is correct.
</Card.Text>
),
});
break;
case 'MISSING_REQUIRED_CLAIMS':
messages.push({
key: error,
node: (
<Card.Text className="inline">
The OIDC provider does not support the <Code>sub</Code> claim,
which is required for authentication. Your OIDC provider may be
misconfigured.
</Card.Text>
),
});
break;
case 'UNKNOWN_ERROR':
messages.push({
key: error,
node: (
<Card.Text className="inline">
An unknown error occurred during OIDC configuration. Please check
the Headplane logs for more information.
</Card.Text>
),
});
break;
}
}
return messages;
}
+63
View File
@@ -0,0 +1,63 @@
import { AlertCircle } from 'lucide-react';
import Card from '~/components/Card';
import Code from '~/components/Code';
export function OidcErrorNotice({ code }: { code: string }) {
return (
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">Configuration Issue(s)</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{getErrorMessage(code)}
</Card>
);
}
function getErrorMessage(code: string) {
switch (code) {
case 'error_no_query':
return (
<Card.Text>
The SSO provider did not correctly redirect back to Headplane with the
required parameters. Please ensure your SSO provider is configured
correctly.
</Card.Text>
);
case 'error_no_session':
case 'error_invalid_session':
return (
<Card.Text>
Unable to complete SSO login due to missing or invalid session data.
Ensure that your Headplane cookie configuration is correct and that
your browser is accepting cookies.
</Card.Text>
);
case 'error_no_sub':
return (
<Card.Text>
The SSO provider did not return a valid user identifier. Please ensure
your SSO provider is correctly configured to provide the{' '}
<Code>sub</Code> claim.
</Card.Text>
);
case 'error_auth_failed':
return (
<Card.Text>
Authentication with the SSO provider failed. Please tray again later.
Headplane logs may provide more information.
</Card.Text>
);
default:
return (
<Card.Text>
An unknown error occurred during OIDC authentication. Please try again
later.
</Card.Text>
);
}
}
+104 -69
View File
@@ -1,78 +1,80 @@
import { useEffect } from 'react';
import { AlertCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
ActionFunctionArgs,
data,
Form,
LoaderFunctionArgs,
Link as RemixLink,
redirect,
useActionData,
useLoaderData,
useSearchParams,
} from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import type { LoadContext } from '~/server';
import Link from '~/components/Link';
import { OidcConnectorError } from '~/server/web/oidc-connector';
import { useLiveData } from '~/utils/live-data';
import type { Route } from './+types/page';
import { loginAction } from './action';
import { OidcConfigErrorNotice } from './config-error';
import Logout from './logout';
import { OidcErrorNotice } from './oidc-error';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
return redirect('/machines');
} catch {}
const qp = new URL(request.url).searchParams;
const state = qp.get('s') ?? undefined;
// OIDC config cannot be undefined if an OIDC client is set
// Also check if we are in a logout state and skip redirect if we are
const ssoOnly = context.config.oidc?.disable_api_key_login;
if (state !== 'logout' && ssoOnly) {
// This shouldn't be possible, but still a safe sanity check
if (!context.oidc) {
throw data(
'`oidc.disable_api_key_login` was set without a valid OIDC configuration',
{
status: 400,
},
);
}
const urlState = qp.get('s') ?? undefined;
// MARK: This works because the OIDC connector will always return false
// for `isExclusive` if the OIDC config isn't usable.
if (context.oidcConnector?.isExclusive && urlState !== 'logout') {
return redirect('/oidc/start');
}
const isOidcConnectorEnabled = context.oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled
? context.oidcConnector!.errors
: [];
return {
oidc: context.oidc,
state,
isCookieSecureEnabled: context.config.server.cookie_secure,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
};
}
export async function action(request: ActionFunctionArgs<LoadContext>) {
return loginAction(request);
}
export const action = loginAction;
export default function Page() {
const { state, oidc } = useLoaderData<typeof loader>();
const formData = useActionData<typeof action>();
export default function Page({ loaderData, actionData }: Route.ComponentProps) {
const {
isCookieSecureEnabled,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
} = loaderData;
const [showCookieWarning, setShowCookieWarning] = useState(false);
const [params] = useSearchParams();
const { pause } = useLiveData();
useEffect(() => {
// This page does NOT need stale while revalidate logic
pause();
if (isCookieSecureEnabled && window.location.protocol !== 'https:') {
setShowCookieWarning(true);
}
});
useEffect(() => {
// State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops.
if (state !== null) {
if (urlState !== null) {
const searchParams = new URLSearchParams(params);
searchParams.delete('s');
@@ -85,48 +87,81 @@ export default function Page() {
window.history.replaceState(null, '', newUrl);
}
}, [state, params]);
}, [urlState, params]);
if (state === 'logout') {
if (urlState === 'logout') {
return <Logout />;
}
return (
<div className="flex w-screen h-screen items-center justify-center">
<Card className="max-w-md m-4 sm:m-0">
<Card.Title>Welcome to Headplane</Card.Title>
<Form method="POST">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
<Input
className="mt-8 mb-2"
isRequired
label="API Key"
labelHidden
name="api_key"
placeholder="API Key"
type="password"
/>
{formData?.success === false ? (
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
{formData.message}
</Card.Text>
) : undefined}
<Button className="w-full" type="submit" variant="heavy">
Sign In
</Button>
</Form>
{oidc ? (
<RemixLink to="/oidc/start">
<Button className="w-full mt-2" variant="light">
Single Sign-On
</Button>
</RemixLink>
<div>
{urlState?.startsWith('error_') ? (
<OidcErrorNotice code={urlState} />
) : oidcErrorCodes.length > 0 ? (
<OidcConfigErrorNotice errors={oidcErrorCodes} />
) : showCookieWarning ? (
<Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
<div className="flex items-center justify-between gap-4">
<Card.Title className="text-red-500">
Configuration Issue
</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
{showCookieWarning ? (
<Card.Text className="text-sm">
Headplane is configured to use secure cookies, but this site is
being served over an insecure connection and login will not work
correctly.{' '}
<Link
name="Headplane Common Issues"
to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
>
Learn more.
</Link>
</Card.Text>
) : undefined}
</Card>
) : undefined}
</Card>
<Card className="max-w-md m-4 sm:m-0">
<Card.Title>Welcome to Headplane</Card.Title>
<Form method="POST">
<Card.Text>
Enter an API key to authenticate with Headplane. You can generate
one by running <Code>headscale apikeys create</Code> in your
terminal.
</Card.Text>
<Input
className="mt-8 mb-2"
isRequired
label="API Key"
labelHidden
name="api_key"
placeholder="API Key"
type="password"
/>
{actionData?.success === false ? (
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300">
{actionData.message}
</Card.Text>
) : undefined}
<Button className="w-full" type="submit" variant="heavy">
Sign In
</Button>
</Form>
{isOidcConnectorEnabled ? (
<RemixLink to="/oidc/start">
<Button
className="w-full mt-2"
isDisabled={oidcErrorCodes.length > 0}
variant="light"
>
Single Sign-On
</Button>
</RemixLink>
) : undefined}
</Card>
</div>
</div>
);
}
+96 -78
View File
@@ -1,75 +1,99 @@
import { createHash } from 'node:crypto';
import { count, eq } from 'drizzle-orm';
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
import * as oidc from 'openid-client';
import {
createCookie,
data,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import { ulid } from 'ulidx';
import type { LoadContext } from '~/server';
import { HeadplaneConfig } from '~/server/config/schema';
import { users } from '~/server/db/schema';
import { Roles } from '~/server/web/roles';
import { FlowUser, finishAuthFlow, formatError } from '~/utils/oidc';
import { send } from '~/utils/res';
interface OidcFlowSession {
state: string;
nonce: string;
code_verifier: string;
redirect_uri: string;
}
import log from '~/utils/log';
import type { OidcCookieState } from './oidc-start';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
if (!context.oidcConnector?.isValid) {
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
// Check if we have 0 query parameters
const url = new URL(request.url);
if (url.searchParams.toString().length === 0) {
return redirect('/login');
return redirect('/login?s=error_no_query');
}
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300, // 5 minutes
maxAge: 300,
secure: context.config.server.cookie_secure,
domain: context.config.server.cookie_domain,
});
const data: OidcFlowSession | null = await cookie.parse(
const oidcCookieState: OidcCookieState | null = await cookie.parse(
request.headers.get('Cookie'),
);
if (data === null) {
console.warn('OIDC flow session not found');
return redirect('/login');
if (oidcCookieState == null || typeof oidcCookieState !== 'object') {
log.warn('auth', 'Called OIDC callback without session cookie');
return redirect('/login?s=error_no_session');
}
const { code_verifier, state, nonce, redirect_uri } = data;
if (!code_verifier || !state || !nonce || !redirect_uri) {
return send({ error: 'Missing OIDC state' }, { status: 400 });
const { state, nonce } = oidcCookieState;
if (!state || !nonce) {
log.warn('auth', 'OIDC session cookie is missing required fields');
return redirect('/login?s=error_invalid_session');
}
// Reconstruct the redirect URI using the query parameters
// and the one we saved in the session
const flowRedirectUri = new URL(redirect_uri);
flowRedirectUri.search = url.search;
const flowOptions = {
redirect_uri: flowRedirectUri.toString(),
code_verifier,
state,
nonce: nonce === '<none>' ? undefined : nonce,
};
try {
let user = await finishAuthFlow(context.oidc, flowOptions);
user = {
...user,
picture: setOidcPictureForSource(
user,
context.config.oidc?.profile_picture_source ?? 'oidc',
),
};
const tokens = await oidc.authorizationCodeGrant(
context.oidcConnector.client,
request,
{
expectedState: state,
expectedNonce: nonce,
},
);
const claims = tokens.claims();
if (claims?.sub == null) {
log.warn('auth', 'No subject found in OIDC claims');
return redirect('/login?s=error_no_sub');
}
const userInfo = await oidc.fetchUserInfo(
context.oidcConnector.client,
tokens.access_token,
claims.sub,
);
// We have defaults that closely follow what Headscale uses, maybe we
// can make it configurable in the future, but for now we only need the
// `sub` claim.
const username =
userInfo.preferred_username ?? userInfo.email?.split('@')[0] ?? 'user';
const name =
userInfo.name ??
(userInfo.given_name && userInfo.family_name
? `${userInfo.given_name} ${userInfo.family_name}`
: (userInfo.preferred_username ?? 'SSO User'));
const picture =
context.config.oidc?.profile_picture_source === 'gravatar'
? (() => {
if (!userInfo.email) {
return undefined;
}
const emailHash = userInfo.email.trim().toLowerCase();
const hash = createHash('sha256').update(emailHash).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
})()
: userInfo.picture;
const [{ count: userCount }] = await context.db
.select({ count: count() })
@@ -80,52 +104,46 @@ export async function loader({
.insert(users)
.values({
id: ulid(),
sub: user.subject,
sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member,
})
.onConflictDoNothing();
return redirect('/machines', {
return redirect('/', {
headers: {
'Set-Cookie': await context.sessions.createSession({
// TODO: This is breaking, to stop the "over-generation" of API
// keys because they are currently non-deletable in the headscale
// database. Look at this in the future once we have a solution
// or we have permissioned API keys.
api_key: context.config.oidc?.headscale_api_key!,
user,
api_key: context.oidcConnector.apiKey,
user: {
subject: claims.sub,
username,
name,
email: userInfo.email,
picture,
},
}),
},
});
} catch (error) {
return new Response(JSON.stringify(formatError(error)), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
}
type PictureSource = NonNullable<
HeadplaneConfig['oidc']
>['profile_picture_source'];
function setOidcPictureForSource(user: FlowUser, source: PictureSource) {
// Already set by default in the callback, so we can just return it
if (source === 'oidc') {
return user.picture;
}
if (source === 'gravatar') {
if (!user.email) {
return undefined;
if (error instanceof oidc.ResponseBodyError) {
log.error(
'auth',
'Got an OIDC response error body: %s',
JSON.stringify(error.cause),
);
}
const emailHash = user.email.trim().toLowerCase();
const hash = createHash('sha256').update(emailHash).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
}
if (error instanceof oidc.AuthorizationResponseError) {
log.error(
'auth',
'Got an OIDC authorization response error: %s',
error.error,
);
}
return undefined;
if (error instanceof oidc.WWWAuthenticateChallengeError) {
log.error('auth', 'Got an OIDC WWW-Authenticate challenge error');
}
return redirect('/login?s=error_auth_failed');
}
}
+55 -18
View File
@@ -1,6 +1,16 @@
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router';
import * as oidc from 'openid-client';
import {
createCookie,
data,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import type { LoadContext } from '~/server';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc';
export interface OidcCookieState {
nonce: string;
state: string;
}
export async function loader({
request,
@@ -8,36 +18,63 @@ export async function loader({
}: LoaderFunctionArgs<LoadContext>) {
try {
await context.sessions.auth(request);
return redirect('/machines');
return redirect('/');
} catch {}
if (!context.oidc || !context.config.oidc) {
throw new Error('OIDC is not enabled');
if (!context.oidcConnector?.isValid) {
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300, // 5 minutes
maxAge: 300,
secure: context.config.server.cookie_secure,
domain: context.config.server.cookie_domain,
});
const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const data = await beginAuthFlow(
context.oidc,
redirectUri,
context.config.oidc.scope,
context.config.oidc.extra_params,
);
return redirect(data.url, {
const nonce = oidc.randomNonce();
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(context.oidcConnector.client, {
...(context.oidcConnector.extraParams ?? {}),
scope: context.oidcConnector.scope,
redirect_uri: redirectUri,
state,
nonce,
});
return redirect(url.href, {
status: 302,
headers: {
'Set-Cookie': await cookie.serialize({
state: data.state,
nonce: data.nonce,
code_verifier: data.codeVerifier,
redirect_uri: redirectUri,
}),
state,
nonce,
} satisfies OidcCookieState),
},
});
}
function getRedirectUri(req: Request) {
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
let host = req.headers.get('Host');
if (!host) {
host = req.headers.get('X-Forwarded-Host');
}
if (!host) {
throw data(
'Cannot determine redirect URI: no Host or X-Forwarded-Host header',
{
status: 500,
},
);
}
const proto = req.headers.get('X-Forwarded-Proto');
url.protocol = proto ?? 'http:';
url.host = host;
return url.href;
}
+205 -225
View File
@@ -1,11 +1,8 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { data } from 'react-router';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
export async function dnsAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
export async function dnsAction({ request, context }: Route.ActionArgs) {
const check = await context.sessions.check(
request,
Capabilities.write_network,
@@ -19,6 +16,9 @@ export async function dnsAction({
return data({ success: false }, 403);
}
// We only need it for health checks which don't require auth
const api = context.hsApi.getRuntimeClient('fake-api-key');
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
@@ -26,226 +26,206 @@ export async function dnsAction({
}
switch (action) {
case 'rename_tailnet':
return renameTailnet(formData, context);
case 'toggle_magic':
return toggleMagic(formData, context);
case 'remove_ns':
return removeNs(formData, context);
case 'add_ns':
return addNs(formData, context);
case 'remove_domain':
return removeDomain(formData, context);
case 'add_domain':
return addDomain(formData, context);
case 'remove_record':
return removeRecord(formData, context);
case 'add_record':
return addRecord(formData, context);
case 'override_dns':
return overrideDns(formData, context);
case 'rename_tailnet': {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.base_domain',
value: newName,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Tailnet renamed successfully' };
}
case 'toggle_magic': {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Magic DNS state updated successfully' };
}
case 'remove_ns': {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers.length > 0 ? servers : null,
},
]);
}
await context.integration?.onConfigChange(api);
return { message: 'Nameserver removed successfully' };
}
case 'add_ns': {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await context.integration?.onConfigChange(api);
return { message: 'Nameserver added successfully' };
}
case 'remove_domain': {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Domain removed successfully' };
}
case 'add_domain': {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains;
domains.push(domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'Domain added successfully' };
}
case 'remove_record': {
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
// Value is not needed for removal
const restart = await context.hs.removeDNS({
name: recordName,
type: recordType,
value: '',
});
if (!restart) {
return;
}
await context.integration?.onConfigChange(api);
return { message: 'DNS record removed successfully' };
}
case 'add_record': {
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
const restart = await context.hs.addDNS({
name: recordName,
type: recordType,
value: recordValue,
});
if (!restart) {
return;
}
await context.integration?.onConfigChange(api);
return { message: 'DNS record added successfully' };
}
case 'override_dns': {
const override = formData.get('override_dns')?.toString();
if (!override) {
return data({ success: false }, 400);
}
const overrideValue = override === 'true';
await context.hs.patch([
{
path: 'dns.override_local_dns',
value: overrideValue,
},
]);
await context.integration?.onConfigChange(api);
return { message: 'DNS override updated successfully' };
}
default:
return data({ success: false }, 400);
}
}
async function renameTailnet(formData: FormData, context: LoadContext) {
const newName = formData.get('new_name')?.toString();
if (!newName) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.base_domain',
value: newName,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function toggleMagic(formData: FormData, context: LoadContext) {
const newState = formData.get('new_state')?.toString();
if (!newState) {
return data({ success: false }, 400);
}
await context.hs.patch([
{
path: 'dns.magic_dns',
value: newState === 'enabled',
},
]);
await context.integration?.onConfigChange(context.client);
}
async function removeNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global.filter((i) => i !== ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName].filter((i) => i !== ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers.length > 0 ? servers : null,
},
]);
}
await context.integration?.onConfigChange(context.client);
}
async function addNs(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const ns = formData.get('ns')?.toString();
const splitName = formData.get('split_name')?.toString();
if (!ns || !splitName) {
return data({ success: false }, 400);
}
if (splitName === 'global') {
const servers = config.dns.nameservers.global;
servers.push(ns);
await context.hs.patch([
{
path: 'dns.nameservers.global',
value: servers,
},
]);
} else {
const splits = config.dns.nameservers.split;
const servers = splits[splitName] ?? [];
servers.push(ns);
await context.hs.patch([
{
path: `dns.nameservers.split."${splitName}"`,
value: servers,
},
]);
}
await context.integration?.onConfigChange(context.client);
}
async function removeDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains.filter((i) => i !== domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function addDomain(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const domain = formData.get('domain')?.toString();
if (!domain) {
return data({ success: false }, 400);
}
const domains = config.dns.search_domains;
domains.push(domain);
await context.hs.patch([
{
path: 'dns.search_domains',
value: domains,
},
]);
await context.integration?.onConfigChange(context.client);
}
async function removeRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
if (!recordName || !recordType) {
return data({ success: false }, 400);
}
// Value is not needed for removal
const restart = await context.hs.removeDNS({
name: recordName,
type: recordType,
value: '',
});
if (!restart) {
return;
}
await context.integration?.onConfigChange(context.client);
}
async function addRecord(formData: FormData, context: LoadContext) {
const config = context.hs.c!;
const recordName = formData.get('record_name')?.toString();
const recordType = formData.get('record_type')?.toString();
const recordValue = formData.get('record_value')?.toString();
if (!recordName || !recordType || !recordValue) {
return data({ success: false }, 400);
}
const restart = await context.hs.addDNS({
name: recordName,
type: recordType,
value: recordValue,
});
if (!restart) {
return;
}
await context.integration?.onConfigChange(context.client);
}
async function overrideDns(formData: FormData, context: LoadContext) {
const override = formData.get('override_dns')?.toString();
if (!override) {
return data({ success: false }, 400);
}
const overrideValue = override === 'true';
await context.hs.patch([
{
path: 'dns.override_local_dns',
value: overrideValue,
},
]);
await context.integration?.onConfigChange(context.client);
}
@@ -22,6 +22,7 @@ interface Props {
isAgent?: boolean;
magic?: string;
isDisabled?: boolean;
existingTags?: string[];
}
export default function MachineRow({
@@ -30,6 +31,7 @@ export default function MachineRow({
isAgent,
magic,
isDisabled,
existingTags,
}: Props) {
const uiTags = useMemo(() => {
const tags = uiTagsForNode(node, isAgent);
@@ -64,7 +66,10 @@ export default function MachineRow({
{node.givenName}
</p>
<p className="text-sm opacity-50">
{node.user.name || node.user.displayName || node.user.email || node.user.id}
{node.user.name ||
node.user.displayName ||
node.user.email ||
node.user.id}
</p>
<div className="flex gap-1 flex-wrap mt-1.5">
{mapTagsToComponents(node, uiTags)}
@@ -143,6 +148,7 @@ export default function MachineRow({
</td>
<td className="py-2 pr-0.5">
<MenuOptions
existingTags={existingTags}
isDisabled={isDisabled}
magic={magic}
node={node}
+25 -21
View File
@@ -11,12 +11,14 @@ import Move from '../dialogs/move';
import Rename from '../dialogs/rename';
import Routes from '../dialogs/routes';
import Tags from '../dialogs/tags';
interface MenuProps {
node: PopulatedNode;
users: User[];
magic?: string;
isFullButton?: boolean;
isDisabled?: boolean;
existingTags?: string[];
}
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
@@ -27,6 +29,7 @@ export default function MachineMenu({
users,
isFullButton,
isDisabled,
existingTags,
}: MenuProps) {
const [modal, setModal] = useState<Modal>(null);
const supportsTailscaleSSH =
@@ -36,8 +39,8 @@ export default function MachineMenu({
<div className="flex items-center justify-end px-4 gap-1.5">
{modal === 'remove' && (
<Delete
machine={node}
isOpen={modal === 'remove'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
@@ -45,19 +48,19 @@ export default function MachineMenu({
)}
{modal === 'move' && (
<Move
machine={node}
users={users}
isOpen={modal === 'move'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
users={users}
/>
)}
{modal === 'rename' && (
<Rename
isOpen={modal === 'rename'}
machine={node}
magic={magic}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
@@ -65,8 +68,8 @@ export default function MachineMenu({
)}
{modal === 'routes' && (
<Routes
node={node}
isOpen={modal === 'routes'}
node={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
@@ -74,8 +77,9 @@ export default function MachineMenu({
)}
{modal === 'tags' && (
<Tags
machine={node}
existingTags={existingTags}
isOpen={modal === 'tags'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
@@ -83,8 +87,8 @@ export default function MachineMenu({
)}
{node.expired && modal === 'expire' ? undefined : (
<Expire
machine={node}
isOpen={modal === 'expire'}
machine={node}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
@@ -95,33 +99,23 @@ export default function MachineMenu({
isFullButton ? (
<Button
className="flex items-center gap-x-2"
variant="heavy"
onPress={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.name}`,
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
'_blank',
'noopener,noreferrer,width=800,height=600',
);
}}
variant="heavy"
>
<SquareTerminal className="h-5" />
<p>SSH</p>
</Button>
) : (
<Button
onPress={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.name}`,
'_blank',
'noopener,noreferrer,width=800,height=600',
);
}}
className={cn(
'py-0.5 w-fit bg-transparent border-transparent',
'border group-hover:border-headplane-200',
@@ -129,6 +123,16 @@ export default function MachineMenu({
'opacity-0 pointer-events-none group-hover:opacity-100',
'group-hover:pointer-events-auto',
)}
onPress={() => {
// We need to use JS to open the SSH URL
// in a new WINDOW since href can only
// do a new TAB.
window.open(
`${__PREFIX__}/ssh?hostname=${node.givenName}`,
'_blank',
'noopener,noreferrer,width=800,height=600',
);
}}
>
SSH
</Button>
@@ -142,19 +146,19 @@ export default function MachineMenu({
</Menu.Button>
) : (
<Menu.IconButton
label="Machine Options"
className={cn(
'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
)}
label="Machine Options"
>
<Ellipsis className="h-5" />
</Menu.IconButton>
)}
<Menu.Panel
onAction={(key) => setModal(key as Modal)}
disabledKeys={node.expired ? ['expire'] : []}
onAction={(key) => setModal(key as Modal)}
>
<Menu.Section>
<Menu.Item key="rename">Edit machine name</Menu.Item>
+3 -3
View File
@@ -14,16 +14,16 @@ export default function Delete({ machine, isOpen, setIsOpen }: DeleteProps) {
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel
variant="destructive"
onSubmit={() => navigate('/machines')}
variant="destructive"
>
<Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text>
This machine will be permanently removed from your network. To re-add
it, you will need to reauthenticate to your tailnet from the device.
</Dialog.Text>
<input type="hidden" name="action_id" value="delete" />
<input type="hidden" name="node_id" value={machine.id} />
<input name="action_id" type="hidden" value="delete" />
<input name="node_id" type="hidden" value={machine.id} />
</Dialog.Panel>
</Dialog>
);
+8 -6
View File
@@ -20,21 +20,23 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
<Dialog.Text>
The owner of the machine is the user associated with it.
</Dialog.Text>
<input type="hidden" name="action_id" value="reassign" />
<input type="hidden" name="node_id" value={machine.id} />
<input type="hidden" name="user_id" value={userId?.toString()} />
<input name="action_id" type="hidden" value="reassign" />
<input name="node_id" type="hidden" value={machine.id} />
<input name="user_id" type="hidden" value={userId?.toString()} />
<Select
defaultSelectedKey={machine.user.id}
isRequired
label="Owner"
name="user"
placeholder="Select a user"
defaultSelectedKey={machine.user.id}
onSelectionChange={(key) => {
setUserId(key);
}}
placeholder="Select a user"
>
{users.map((user) => (
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
</Dialog.Panel>
+7 -5
View File
@@ -30,14 +30,14 @@ export default function NewMachine(data: NewMachineProps) {
<Code isCopyable>tailscale up --login-server={data.server}</Code> on
your device.
</Dialog.Text>
<input type="hidden" name="action_id" value="register" />
<input name="action_id" type="hidden" value="register" />
<Input
isRequired
label="Machine Key"
placeholder="AbCd..."
validationBehavior="native"
name="register_key"
onChange={setMkey}
placeholder="AbCd..."
validationBehavior="native"
/>
<Select
isRequired
@@ -46,12 +46,14 @@ export default function NewMachine(data: NewMachineProps) {
placeholder="Select a user"
>
{data.users.map((user) => (
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
</Dialog.Panel>
</Dialog>
<Menu isDisabled={data.isDisabled} disabledKeys={data.disabledKeys}>
<Menu disabledKeys={data.disabledKeys} isDisabled={data.isDisabled}>
<Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel
onAction={(key) => {
+5 -5
View File
@@ -27,16 +27,15 @@ export default function Rename({
This name is shown in the admin panel, in Tailscale clients, and used
when generating MagicDNS names.
</Dialog.Text>
<input type="hidden" name="action_id" value="rename" />
<input type="hidden" name="node_id" value={machine.id} />
<input name="action_id" type="hidden" value="rename" />
<input name="node_id" type="hidden" value={machine.id} />
<Input
defaultValue={machine.givenName}
isRequired
label="Machine name"
placeholder="Machine name"
validationBehavior="native"
name="name"
defaultValue={machine.givenName}
onChange={setName}
placeholder="Machine name"
validate={(value) => {
if (value.length === 0) {
return 'Cannot be empty';
@@ -66,6 +65,7 @@ export default function Rename({
return 'Cannot contain consecutive hyphens';
}
}}
validationBehavior="native"
/>
{magic ? (
name.length > 0 && name !== machine.givenName ? (
+2 -2
View File
@@ -30,8 +30,8 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
Connect to devices you can&apos;t install Tailscale on by advertising
IP ranges as subnet routes.{' '}
<Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation"
to="https://tailscale.com/kb/1019/subnets"
>
Learn More
</Link>
@@ -70,8 +70,8 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
<Dialog.Text>
Allow your network to route internet traffic through this machine.{' '}
<Link
to="https://tailscale.com/kb/1103/exit-nodes"
name="Tailscale Exit-node Documentation"
to="https://tailscale.com/kb/1103/exit-nodes"
>
Learn More
</Link>
+51 -46
View File
@@ -1,9 +1,9 @@
import { Plus, TagsIcon, X } from 'lucide-react';
import { useState } from 'react';
import { useMemo, useState } from 'react';
import Button from '~/components/Button';
import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Link from '~/components/Link';
import Select from '~/components/Select';
import TableList from '~/components/TableList';
import type { Machine } from '~/types';
import cn from '~/utils/cn';
@@ -12,11 +12,24 @@ interface TagsProps {
machine: Machine;
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
existingTags?: string[];
}
export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
export default function Tags({
machine,
isOpen,
setIsOpen,
existingTags,
}: TagsProps) {
const [tags, setTags] = useState(machine.forcedTags);
const [tag, setTag] = useState('');
const [tag, setTag] = useState('tag:');
const tagIsInvalid = useMemo(() => {
return tag.length === 0 || !tag.startsWith('tag:') || tags.includes(tag);
}, [tag, tags]);
const validNodeTags = useMemo(() => {
return existingTags?.filter((nodeTag) => !tags.includes(nodeTag)) || [];
}, [tags]);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
@@ -26,16 +39,16 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
ACL tags can be used to reference machines in your ACL policies. See
the{' '}
<Link
to="https://tailscale.com/kb/1068/acl-tags"
name="Tailscale documentation"
to="https://tailscale.com/kb/1068/acl-tags"
>
Tailscale documentation
</Link>{' '}
for more information.
</Dialog.Text>
<input type="hidden" name="action_id" value="update_tags" />
<input type="hidden" name="node_id" value={machine.id} />
<input type="hidden" name="tags" value={tags.join(',')} />
<input name="action_id" type="hidden" value="update_tags" />
<input name="node_id" type="hidden" value={machine.id} />
<input name="tags" type="hidden" value={tags.join(',')} />
<TableList className="mt-4">
{tags.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
@@ -44,7 +57,7 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
</TableList.Item>
) : (
tags.map((item) => (
<TableList.Item className="font-mono" key={item} id={item}>
<TableList.Item className="font-mono" id={item} key={item}>
{item}
<Button
className="rounded-md p-0.5"
@@ -57,44 +70,36 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
</TableList.Item>
))
)}
<TableList.Item
className={cn(
'rounded-b-xl focus-within:ring-3',
tag.length > 0 &&
(!tag.startsWith('tag:') || tags.includes(tag)) &&
'ring-3 ring-red-500 ring-opacity-50',
)}
>
<Input
labelHidden
label="Add a tag"
placeholder="tag:example"
onChange={setTag}
className={cn(
'border-none font-mono p-0',
'rounded-none focus:ring-0 w-full',
)}
/>
<Button
className={cn(
'rounded-md p-0.5',
(!tag.startsWith('tag:') || tags.includes(tag)) &&
'opacity-50 cursor-not-allowed',
)}
isDisabled={
tag.length === 0 ||
!tag.startsWith('tag:') ||
tags.includes(tag)
}
onPress={() => {
setTags([...tags, tag]);
setTag('');
}}
>
<Plus className="p-1" />
</Button>
</TableList.Item>
</TableList>
<div className="flex items-center gap-2 mt-2">
<Select
allowsCustomValue
aria-label="Add a tag"
className="w-full"
inputValue={tag}
isInvalid={tag.length > 0 && tagIsInvalid}
onInputChange={setTag}
placeholder="tag:example"
>
{validNodeTags.map((nodeTag) => {
return <Select.Item key={nodeTag}>{nodeTag}</Select.Item>;
})}
</Select>
<Button
className={cn(
'rounded-md p-1',
tagIsInvalid && 'opacity-50 cursor-not-allowed',
)}
isDisabled={tagIsInvalid}
onPress={() => {
setTags([...tags, tag]);
setTag('tag:');
}}
>
<Plus className="p-1" size={30} />
</Button>
</div>
</Dialog.Panel>
</Dialog>
);
+103 -191
View File
@@ -1,12 +1,8 @@
import { type ActionFunctionArgs, data, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { data, redirect } from 'react-router';
import { Capabilities } from '~/server/web/roles';
import { Machine } from '~/types';
import type { Route } from './+types/machine';
export async function machineAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
export async function machineAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
@@ -14,7 +10,7 @@ export async function machineAction({
);
const formData = await request.formData();
const apiKey = session.api_key;
const api = context.hsApi.getRuntimeClient(session.api_key);
const action = formData.get('action_id')?.toString();
if (!action) {
@@ -31,7 +27,22 @@ export async function machineAction({
});
}
return registerMachine(formData, apiKey, context);
const registrationKey = formData.get('register_key')?.toString();
if (!registrationKey) {
throw data('Missing `register_key` in the form data.', {
status: 400,
});
}
const user = formData.get('user')?.toString();
if (!user) {
throw data('Missing `user` in the form data.', {
status: 400,
});
}
const node = await api.registerNode(user, registrationKey);
return redirect(`/machines/${node.id}`);
}
// Check if the user has permission to manage this machine
@@ -42,12 +53,7 @@ export async function machineAction({
});
}
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
apiKey,
);
const node = nodes.find((node) => node.id === nodeId);
const node = await api.getNode(nodeId);
if (!node) {
throw data(`Machine with ID ${nodeId} not found`, {
status: 404,
@@ -65,27 +71,103 @@ export async function machineAction({
switch (action) {
case 'rename': {
return renameMachine(formData, apiKey, nodeId, context);
const newName = formData.get('name')?.toString();
if (!newName) {
throw data('Missing `name` in the form data.', {
status: 400,
});
}
const name = String(formData.get('name'));
await api.renameNode(nodeId, name);
return { message: 'Machine renamed' };
}
case 'delete': {
return deleteMachine(apiKey, nodeId, context);
await api.deleteNode(nodeId);
return redirect('/machines');
}
case 'expire': {
return expireMachine(apiKey, nodeId, context);
await api.expireNode(nodeId);
return { message: 'Machine expired' };
}
case 'update_tags': {
return updateTags(formData, apiKey, nodeId, context);
const tags = formData.get('tags')?.toString().split(',') ?? [];
if (tags.length === 0) {
throw data('Missing `tags` in the form data.', {
status: 400,
});
}
await api.setNodeTags(
nodeId,
tags.map((tag) => tag.trim()).filter((tag) => tag !== ''),
);
return { message: 'Tags updated' };
}
case 'update_routes': {
return updateRoutes(formData, apiKey, nodeId, context);
const newApproved = node.approvedRoutes;
const routes = formData.get('routes')?.toString();
if (!routes) {
throw data('Missing `routes` in the form data.', {
status: 400,
});
}
const allRoutes = routes.split(',').map((route) => route.trim());
if (allRoutes.length === 0) {
throw data('No routes provided to update', {
status: 400,
});
}
const enabled = formData.get('enabled')?.toString();
if (enabled === undefined) {
throw data('Missing `enabled` in the form data.', {
status: 400,
});
}
if (enabled === 'true') {
for (const route of allRoutes) {
// If already approved, skip, otherwise add to approved
if (newApproved.includes(route)) {
continue;
}
newApproved.push(route);
}
} else {
for (const route of allRoutes) {
// If not approved, skip, otherwise remove from approved
if (!newApproved.includes(route)) {
continue;
}
const index = newApproved.indexOf(route);
if (index > -1) {
newApproved.splice(index, 1);
}
}
}
await api.approveNodeRoutes(nodeId, newApproved);
return { message: 'Routes updated' };
}
case 'reassign': {
return reassignMachine(formData, apiKey, nodeId, context);
const user = formData.get('user_id')?.toString();
if (!user) {
throw data('Missing `user_id` in the form data.', {
status: 400,
});
}
await api.setNodeUser(nodeId, user);
return { message: 'Machine reassigned' };
}
default:
@@ -94,173 +176,3 @@ export async function machineAction({
});
}
}
async function registerMachine(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const registrationKey = formData.get('register_key')?.toString();
if (!registrationKey) {
throw data('Missing `register_key` in the form data.', {
status: 400,
});
}
const user = formData.get('user')?.toString();
if (!user) {
throw data('Missing `user` in the form data.', {
status: 400,
});
}
const qp = new URLSearchParams();
qp.append('user', user);
qp.append('key', registrationKey);
const url = `v1/node/register?${qp.toString()}`;
const { node } = await context.client.post<{ node: Machine }>(url, apiKey, {
user,
key: registrationKey,
});
return redirect(`/machines/${node.id}`);
}
async function renameMachine(
formData: FormData,
apiKey: string,
nodeId: string,
context: LoadContext,
) {
const newName = formData.get('name')?.toString();
if (!newName) {
throw data('Missing `name` in the form data.', {
status: 400,
});
}
const name = String(formData.get('name'));
await context.client.post(`v1/node/${nodeId}/rename/${name}`, apiKey);
return { message: 'Machine renamed' };
}
async function deleteMachine(
apiKey: string,
nodeId: string,
context: LoadContext,
) {
await context.client.delete(`v1/node/${nodeId}`, apiKey);
return redirect('/machines');
}
async function expireMachine(
apiKey: string,
nodeId: string,
context: LoadContext,
) {
await context.client.post(`v1/node/${nodeId}/expire`, apiKey);
return { message: 'Machine expired' };
}
async function updateTags(
formData: FormData,
apiKey: string,
nodeId: string,
context: LoadContext,
) {
const tags = formData.get('tags')?.toString().split(',') ?? [];
if (tags.length === 0) {
throw data('Missing `tags` in the form data.', {
status: 400,
});
}
await context.client.post(`v1/node/${nodeId}/tags`, apiKey, {
tags: tags.map((tag) => tag.trim()).filter((tag) => tag !== ''),
});
return { message: 'Tags updated' };
}
async function updateRoutes(
formData: FormData,
apiKey: string,
nodeId: string,
context: LoadContext,
) {
const { node } = await context.client.get<{ node: Machine }>(
`v1/node/${nodeId}`,
apiKey,
);
const newApproved = node.approvedRoutes;
const routes = formData.get('routes')?.toString();
if (!routes) {
throw data('Missing `routes` in the form data.', {
status: 400,
});
}
const allRoutes = routes.split(',').map((route) => route.trim());
if (allRoutes.length === 0) {
throw data('No routes provided to update', {
status: 400,
});
}
const enabled = formData.get('enabled')?.toString();
if (enabled === undefined) {
throw data('Missing `enabled` in the form data.', {
status: 400,
});
}
if (enabled === 'true') {
for (const route of allRoutes) {
// If already approved, skip, otherwise add to approved
if (newApproved.includes(route)) {
continue;
}
newApproved.push(route);
}
} else {
for (const route of allRoutes) {
// If not approved, skip, otherwise remove from approved
if (!newApproved.includes(route)) {
continue;
}
const index = newApproved.indexOf(route);
if (index > -1) {
newApproved.splice(index, 1);
}
}
}
await context.client.post(`v1/node/${nodeId}/approve_routes`, apiKey, {
routes: newApproved,
});
return { message: 'Routes updated' };
}
async function reassignMachine(
formData: FormData,
apiKey: string,
nodeId: string,
context: LoadContext,
) {
const user = formData.get('user_id')?.toString();
if (!user) {
throw data('Missing `user_id` in the form data.', {
status: 400,
});
}
await context.client.post(`v1/node/${nodeId}/user`, apiKey, {
user,
});
return { message: 'Machine reassigned' };
}
+27 -28
View File
@@ -1,7 +1,6 @@
import { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { Link as RemixLink, useLoaderData } from 'react-router';
import { data, Link as RemixLink } from 'react-router';
import Attribute from '~/components/Attribute';
import Button from '~/components/Button';
import Card from '~/components/Card';
@@ -9,26 +8,25 @@ import Chip from '~/components/Chip';
import Link from '~/components/Link';
import StatusCircle from '~/components/StatusCircle';
import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import { getOSInfo, getTSVersion } from '~/utils/host-info';
import { mapNodes } from '~/utils/node-info';
import { mapNodes, sortNodeTags } from '~/utils/node-info';
import type { Route } from './+types/machine';
import { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
import MenuOptions from './components/menu';
import Routes from './dialogs/routes';
import { machineAction } from './machine-actions';
export async function loader({
request,
params,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, params, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
if (!params.id) {
throw new Error('No machine ID provided');
}
if (params.id.endsWith('.ico')) {
throw data(null, { status: 204 });
}
let magic: string | undefined;
if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) {
@@ -36,37 +34,32 @@ export async function loader({
}
}
const [machine, { users }] = await Promise.all([
context.client.get<{ node: Machine }>(
`v1/node/${params.id}`,
session.api_key,
),
context.client.get<{ users: User[] }>('v1/user', session.api_key),
]);
const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
const node = nodes.find((node) => node.id === params.id);
const lookup = await context.agents?.lookup([machine.node.nodeKey]);
const [node] = mapNodes([machine.node], lookup);
const lookup = await context.agents?.lookup([node.nodeKey]);
const [enhancedNode] = mapNodes([node], lookup);
const tags = Array.from(
new Set([...node.validTags, ...node.forcedTags]),
).sort();
return {
node,
node: enhancedNode,
tags,
users,
magic,
agent: context.agents?.agentID(),
stats: lookup?.[node.nodeKey],
stats: lookup?.[enhancedNode.nodeKey],
existingTags: sortNodeTags(nodes),
};
}
export async function action(request: ActionFunctionArgs) {
return machineAction(request);
}
export const action = machineAction;
export default function Page() {
const { node, tags, magic, users, agent, stats } =
useLoaderData<typeof loader>();
export default function Page({
loaderData: { node, tags, users, magic, agent, stats, existingTags },
}: Route.ComponentProps) {
const [showRouting, setShowRouting] = useState(false);
const uiTags = useMemo(() => {
@@ -93,7 +86,13 @@ export default function Page() {
<h1 className="text-2xl font-medium">{node.givenName}</h1>
<StatusCircle className="w-4 h-4" isOnline={node.online} />
</span>
<MenuOptions isFullButton magic={magic} node={node} users={users} />
<MenuOptions
existingTags={existingTags}
isFullButton
magic={magic}
node={node}
users={users}
/>
</div>
<div className="flex gap-1 mb-4">
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
+28 -36
View File
@@ -1,22 +1,16 @@
import { Info } from 'lucide-react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import { mapNodes } from '~/utils/node-info';
import { mapNodes, sortNodeTags } from '~/utils/node-info';
import type { Route } from './+types/overview';
import MachineRow from './components/machine-row';
import NewMachine from './dialogs/new';
import { machineAction } from './machine-actions';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const user = session.user;
if (!user) {
@@ -40,10 +34,8 @@ export async function loader({
Capabilities.write_machines,
);
const [{ nodes }, { users }] = await Promise.all([
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
context.client.get<{ users: User[] }>('v1/user', session.api_key),
]);
const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
let magic: string | undefined;
if (context.hs.readable()) {
@@ -72,13 +64,9 @@ export async function loader({
};
}
export async function action(request: ActionFunctionArgs) {
return machineAction(request);
}
export default function Page() {
const data = useLoaderData<typeof loader>();
export const action = machineAction;
export default function Page({ loaderData }: Route.ComponentProps) {
return (
<>
<div className="flex justify-between items-center mb-6">
@@ -95,10 +83,10 @@ export default function Page() {
</p>
</div>
<NewMachine
disabledKeys={data.preAuth ? [] : ['pre-auth']}
isDisabled={!data.writable}
server={data.publicServer ?? data.server}
users={data.users}
disabledKeys={loaderData.preAuth ? [] : ['pre-auth']}
isDisabled={!loaderData.writable}
server={loaderData.publicServer ?? loaderData.server}
users={loaderData.users}
/>
</div>
<table className="table-auto w-full rounded-lg">
@@ -108,7 +96,7 @@ export default function Page() {
<th className="pb-2 w-1/4">
<div className="flex items-center gap-x-1">
<p className="uppercase text-xs font-bold">Addresses</p>
{data.magic ? (
{loaderData.magic ? (
<Tooltip>
<Info className="w-4 h-4" />
<Tooltip.Body className="font-normal">
@@ -116,7 +104,7 @@ export default function Page() {
their name and also at{' '}
<Code>
[name].
{data.magic}
{loaderData.magic}
</Code>
</Tooltip.Body>
</Tooltip>
@@ -124,7 +112,7 @@ export default function Page() {
</div>
</th>
{/* We only want to show the version column if there are agents */}
{data.agent !== undefined ? (
{loaderData.agent !== undefined ? (
<th className="uppercase text-xs font-bold pb-2">Version</th>
) : undefined}
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
@@ -136,18 +124,22 @@ export default function Page() {
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{data.populatedNodes.map((machine) => (
{loaderData.populatedNodes.map((node) => (
<MachineRow
isAgent={data.agent ? data.agent === machine.nodeKey : undefined}
isDisabled={
data.writable
? false // If the user has write permissions, they can edit all machines
: machine.user.providerId?.split('/').pop() !== data.subject
existingTags={sortNodeTags(loaderData.nodes)}
isAgent={
loaderData.agent ? loaderData.agent === node.nodeKey : undefined
}
key={machine.id}
magic={data.magic}
node={machine}
users={data.users}
isDisabled={
loaderData.writable
? false // If the user has write permissions, they can edit all machines
: node.user.providerId?.split('/').pop() !==
loaderData.subject
}
key={node.id}
magic={loaderData.magic}
node={node}
users={loaderData.users}
/>
))}
</tbody>
+66 -89
View File
@@ -1,12 +1,8 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { data } from 'react-router';
import { Capabilities } from '~/server/web/roles';
import { PreAuthKey } from '~/types';
import type { Route } from './+types/overview';
export async function authKeysAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
export async function authKeysAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(
request,
@@ -20,7 +16,7 @@ export async function authKeysAction({
}
const formData = await request.formData();
const apiKey = session.api_key;
const api = context.hsApi.getRuntimeClient(session.api_key);
const action = formData.get('action_id')?.toString();
if (!action) {
throw data('Missing `action_id` in the form data.', {
@@ -29,90 +25,71 @@ export async function authKeysAction({
}
switch (action) {
case 'add_preauthkey':
return await addPreAuthKey(formData, apiKey, context);
case 'expire_preauthkey':
return await expirePreAuthKey(formData, apiKey, context);
case 'add_preauthkey': {
const user = formData.get('user_id')?.toString();
if (!user) {
return data('Missing `user_id` in the form data.', {
status: 400,
});
}
const expiry = formData.get('expiry')?.toString();
if (!expiry) {
return data('Missing `expiry` in the form data.', {
status: 400,
});
}
const reusable = formData.get('reusable')?.toString();
if (!reusable) {
return data('Missing `reusable` in the form data.', {
status: 400,
});
}
const ephemeral = formData.get('ephemeral')?.toString();
if (!ephemeral) {
return data('Missing `ephemeral` in the form data.', {
status: 400,
});
}
// Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(' ')[0]);
const date = new Date();
date.setDate(date.getDate() + day);
await api.createPreAuthKey(
user,
ephemeral === 'on',
reusable === 'on',
date,
[], // TODO
);
return data('Pre-auth key created');
}
case 'expire_preauthkey': {
const key = formData.get('key')?.toString();
if (!key) {
return data('Missing `key` in the form data.', {
status: 400,
});
}
const user = formData.get('user_id')?.toString();
if (!user) {
return data('Missing `user_id` in the form data.', {
status: 400,
});
}
await api.expirePreAuthKey(user, key);
return data('Pre-auth key expired');
}
default:
return data('Invalid action', {
status: 400,
});
}
}
async function addPreAuthKey(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const user = formData.get('user_id')?.toString();
if (!user) {
return data('Missing `user_id` in the form data.', {
status: 400,
});
}
const expiry = formData.get('expiry')?.toString();
if (!expiry) {
return data('Missing `expiry` in the form data.', {
status: 400,
});
}
const reusable = formData.get('reusable')?.toString();
if (!reusable) {
return data('Missing `reusable` in the form data.', {
status: 400,
});
}
const ephemeral = formData.get('ephemeral')?.toString();
if (!ephemeral) {
return data('Missing `ephemeral` in the form data.', {
status: 400,
});
}
// Extract the first "word" from expiry which is the day number
// Calculate the date X days from now using the day number
const day = Number(expiry.toString().split(' ')[0]);
const date = new Date();
date.setDate(date.getDate() + day);
await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
apiKey,
{
user,
ephemeral: ephemeral === 'on',
reusable: reusable === 'on',
expiration: date.toISOString(),
aclTags: [], // TODO
},
);
return data('Pre-auth key created');
}
async function expirePreAuthKey(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const key = formData.get('key')?.toString();
if (!key) {
return data('Missing `key` in the form data.', {
status: 400,
});
}
const user = formData.get('user_id')?.toString();
if (!user) {
return data('Missing `user_id` in the form data.', {
status: 400,
});
}
await context.client.post('v1/preauthkey/expire', apiKey, { user, key });
return data('Pre-auth key expired');
}
@@ -1,5 +1,4 @@
import { Key, useState } from 'react';
import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog';
import Link from '~/components/Link';
import NumberInput from '~/components/NumberInput';
@@ -22,36 +21,38 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
<Dialog.Button className="my-4">Create pre-auth key</Dialog.Button>
<Dialog.Panel>
<Dialog.Title>Generate auth key</Dialog.Title>
<input type="hidden" name="action_id" value="add_preauthkey" />
<input type="hidden" name="user_id" value={userId?.toString()} />
<input name="action_id" type="hidden" value="add_preauthkey" />
<input name="user_id" type="hidden" value={userId?.toString()} />
<Select
className="mb-2"
description="This is the user machines will belong to when they authenticate."
isRequired
label="User"
name="user"
placeholder="Select a user"
description="This is the user machines will belong to when they authenticate."
className="mb-2"
onSelectionChange={(value) => {
setUserId(value);
}}
placeholder="Select a user"
>
{data.users.map((user) => (
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))}
</Select>
<NumberInput
isRequired
name="expiry"
label="Key Expiration"
description="Set this key to expire after a certain number of days."
minValue={1}
maxValue={365_000} // 1000 years
defaultValue={90}
description="Set this key to expire after a certain number of days."
formatOptions={{
style: 'unit',
unit: 'day',
unitDisplay: 'short',
}}
isRequired
label="Key Expiration"
maxValue={365_000} // 1000 years
minValue={1}
name="expiry"
/>
<div className="flex justify-between items-center gap-2 mt-6">
<div>
@@ -61,15 +62,15 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
</Dialog.Text>
</div>
<Switch
defaultSelected={reusable}
label="Reusable"
name="reusable"
defaultSelected={reusable}
onChange={() => {
setReusable(!reusable);
}}
/>
</div>
<input type="hidden" name="reusable" value={reusable.toString()} />
<input name="reusable" type="hidden" value={reusable.toString()} />
<div className="flex justify-between items-center gap-2 mt-6">
<div>
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text>
@@ -77,23 +78,23 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
Devices authenticated with this key will be automatically removed
once they go offline.{' '}
<Link
to="https://tailscale.com/kb/1111/ephemeral-nodes"
name="Tailscale Ephemeral Nodes Documentation"
to="https://tailscale.com/kb/1111/ephemeral-nodes"
>
Learn more
</Link>
</Dialog.Text>
</div>
<Switch
defaultSelected={ephemeral}
label="Ephemeral"
name="ephemeral"
defaultSelected={ephemeral}
onChange={() => {
setEphemeral(!ephemeral);
}}
/>
</div>
<input type="hidden" name="ephemeral" value={ephemeral.toString()} />
<input name="ephemeral" type="hidden" value={ephemeral.toString()} />
</Dialog.Panel>
</Dialog>
);
@@ -12,9 +12,9 @@ export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
<Dialog.Button variant="heavy">Expire Key</Dialog.Button>
<Dialog.Panel variant="destructive">
<Dialog.Title>Expire auth key?</Dialog.Title>
<input type="hidden" name="action_id" value="expire_preauthkey" />
<input type="hidden" name="user_id" value={user.id} />
<input type="hidden" name="key" value={authKey.key} />
<input name="action_id" type="hidden" value="expire_preauthkey" />
<input name="user_id" type="hidden" value={user.id} />
<input name="key" type="hidden" value={authKey.key} />
<Dialog.Text>
Expiring this authentication key will immediately prevent it from
being used to authenticate new devices. This action cannot be undone.
+14 -25
View File
@@ -1,41 +1,29 @@
import { FileKey2 } from 'lucide-react';
import { useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { Link as RemixLink, useLoaderData } from 'react-router';
import { Link as RemixLink } from 'react-router';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Select from '~/components/Select';
import TableList from '~/components/TableList';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import type { PreAuthKey, User } from '~/types';
import log from '~/utils/log';
import type { Route } from './+types/overview';
import { authKeysAction } from './actions';
import AuthKeyRow from './auth-key-row';
import AddAuthKey from './dialogs/add-auth-key';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.api_key,
);
const api = context.hsApi.getRuntimeClient(session.api_key);
const users = await api.getUsers();
const preAuthKeys = await Promise.all(
users
.filter((user) => user.name?.length > 0) // Filter out any invalid users
.map(async (user) => {
const qp = new URLSearchParams();
qp.set('user', user.id);
try {
const { preAuthKeys } = await context.client.get<{
preAuthKeys: PreAuthKey[];
}>(`v1/preauthkey?${qp.toString()}`, session.api_key);
const preAuthKeys = await api.getPreAuthKeys(user.id);
return {
success: true,
user,
@@ -47,7 +35,7 @@ export async function loader({
success: false,
user,
error,
preAuthKeys: [] as PreAuthKey[],
preAuthKeys: [],
};
}
}),
@@ -79,13 +67,12 @@ export async function loader({
};
}
export async function action(request: ActionFunctionArgs<LoadContext>) {
return authKeysAction(request);
}
export const action = authKeysAction;
type Status = 'all' | 'active' | 'expired' | 'reusable' | 'ephemeral';
export default function Page() {
const { keys, missing, users, url, access } = useLoaderData<typeof loader>();
export default function Page({
loaderData: { keys, missing, users, url, access },
}: Route.ComponentProps) {
const [selectedUser, setSelectedUser] = useState('__headplane_all');
const [status, setStatus] = useState<Status>('active');
const isDisabled =
@@ -198,7 +185,9 @@ export default function Page() {
{[
<Select.Item key="__headplane_all">All</Select.Item>,
...keys.map(({ user }) => (
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item>
<Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
)),
]}
</Select>
+8 -12
View File
@@ -1,22 +1,18 @@
import { ArrowRight } from 'lucide-react';
import {
LoaderFunctionArgs,
Link as RemixLink,
useLoaderData,
} from 'react-router';
import { Link as RemixLink } from 'react-router';
import Link from '~/components/Link';
import { LoadContext } from '~/server';
import type { Route } from './+types/overview';
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
export async function loader({ context }: Route.LoaderArgs) {
return {
config: context.hs.writable(),
oidc: context.oidc,
isOidcEnabled: context.oidcConnector?.isValid ?? false,
};
}
export default function Page() {
const { config, oidc } = useLoaderData<typeof loader>();
export default function Page({
loaderData: { config, isOidcEnabled },
}: Route.ComponentProps) {
return (
<div className="flex flex-col gap-8 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-2/3">
@@ -47,7 +43,7 @@ export default function Page() {
<ArrowRight className="w-5 h-5 ml-2" />
</div>
</RemixLink>
{config && oidc ? (
{config && isOidcEnabled ? (
<>
<div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4">
+142 -165
View File
@@ -1,11 +1,11 @@
import { ActionFunctionArgs, data } from 'react-router';
import { LoadContext } from '~/server';
import { data } from 'react-router';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
export async function restrictionAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
}: Route.ActionArgs) {
const check = await context.sessions.check(
request,
Capabilities.configure_iam,
@@ -31,29 +31,162 @@ export async function restrictionAction({
});
}
// We only need healthchecks which don't rely on an API key
const api = context.hsApi.getRuntimeClient('fake-api-key');
switch (action) {
case 'add_domain': {
return addDomain(formData, context);
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const domains = [
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
];
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(api);
return data('Domain added successfully.');
}
case 'remove_domain': {
return removeDomain(formData, context);
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
if (!storedDomains.includes(domain)) {
// Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, {
status: 400,
});
}
// Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain);
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(api);
return data('Domain removed successfully.');
}
case 'add_group': {
return addGroup(formData, context);
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const groups = [
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
];
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
context.integration?.onConfigChange(api);
return data('Group added successfully.');
}
case 'remove_group': {
return removeGroup(formData, context);
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
if (!storedGroups.includes(group)) {
// Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
status: 400,
});
}
// Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group);
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
context.integration?.onConfigChange(api);
return data('Group removed successfully.');
}
case 'add_user': {
return addUser(formData, context);
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
const users = [
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
];
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
context.integration?.onConfigChange(api);
return data('User added successfully.');
}
case 'remove_user': {
return removeUser(formData, context);
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
if (!storedUsers.includes(user)) {
// User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
status: 400,
});
}
// Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user);
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
context.integration?.onConfigChange(api);
return data('User removed successfully.');
}
default: {
@@ -63,159 +196,3 @@ export async function restrictionAction({
}
}
}
async function addDomain(formData: FormData, context: LoadContext) {
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const domains = [
...new Set([...(context.hs.c?.oidc?.allowed_domains ?? []), domain]),
];
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(context.client);
return data('Domain added successfully.');
}
async function removeDomain(formData: FormData, context: LoadContext) {
const domain = formData.get('domain')?.toString()?.trim();
if (!domain) {
throw data('No domain provided.', {
status: 400,
});
}
const storedDomains = context.hs.c?.oidc?.allowed_domains ?? [];
if (!storedDomains.includes(domain)) {
// Domain not found in the list
throw data(`Domain "${domain}" not found in allowed domains.`, {
status: 400,
});
}
// Filter out the domain to remove it from the list
const domains = storedDomains.filter((d: string) => d !== domain);
await context.hs.patch([
{
path: 'oidc.allowed_domains',
value: domains,
},
]);
context.integration?.onConfigChange(context.client);
return data('Domain removed successfully.');
}
async function addUser(formData: FormData, context: LoadContext) {
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
const users = [
...new Set([...(context.hs.c?.oidc?.allowed_users ?? []), user]),
];
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
context.integration?.onConfigChange(context.client);
return data('User added successfully.');
}
async function removeUser(formData: FormData, context: LoadContext) {
const user = formData.get('user')?.toString()?.trim();
if (!user) {
throw data('No user provided.', {
status: 400,
});
}
const storedUsers = context.hs.c?.oidc?.allowed_users ?? [];
if (!storedUsers.includes(user)) {
// User not found in the list
throw data(`User "${user}" not found in allowed users.`, {
status: 400,
});
}
// Filter out the user to remove it from the list
const users = storedUsers.filter((d: string) => d !== user);
await context.hs.patch([
{
path: 'oidc.allowed_users',
value: users,
},
]);
context.integration?.onConfigChange(context.client);
return data('User removed successfully.');
}
async function addGroup(formData: FormData, context: LoadContext) {
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const groups = [
...new Set([...(context.hs.c?.oidc?.allowed_groups ?? []), group]),
];
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
context.integration?.onConfigChange(context.client);
return data('Group added successfully.');
}
async function removeGroup(formData: FormData, context: LoadContext) {
const group = formData.get('group')?.toString()?.trim();
if (!group) {
throw data('No group provided.', {
status: 400,
});
}
const storedGroups = context.hs.c?.oidc?.allowed_groups ?? [];
if (!storedGroups.includes(group)) {
// Group not found in the list
throw data(`Group "${group}" not found in allowed groups.`, {
status: 400,
});
}
// Filter out the group to remove it from the list
const groups = storedGroups.filter((d: string) => d !== group);
await context.hs.patch([
{
path: 'oidc.allowed_groups',
value: groups,
},
]);
context.integration?.onConfigChange(context.client);
return data('Group removed successfully.');
}
@@ -39,19 +39,19 @@ export default function AddDomain({ domains, isDisabled }: AddDomainProps) {
Add this domain to a list of allowed email domains that can
authenticate with Headscale via OIDC.
</Dialog.Text>
<input type="hidden" name="action_id" value="add_domain" />
<input name="action_id" type="hidden" value="add_domain" />
<Input
isRequired
label="Domain"
description={
domain.trim().length > 0
? `Matches users with <user>@${domain.trim()}`
: 'Enter a domain to match users with their email addresses.'
}
placeholder="example.com"
isInvalid={domain.trim().length === 0 || isInvalid}
isRequired
label="Domain"
name="domain"
onChange={setDomain}
isInvalid={domain.trim().length === 0 || isInvalid}
placeholder="example.com"
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
@@ -30,15 +30,15 @@ export default function AddGroup({ groups, isDisabled }: AddGroupProps) {
Add this group to a list of allowed groups that can authenticate with
Headscale via OIDC.
</Dialog.Text>
<input type="hidden" name="action_id" value="add_group" />
<input name="action_id" type="hidden" value="add_group" />
<Input
description="The group to allow for OIDC authentication."
isInvalid={group.trim().length === 0 || isInvalid}
isRequired
label="Group"
description="The group to allow for OIDC authentication."
placeholder="admin"
name="group"
onChange={setGroup}
isInvalid={group.trim().length === 0 || isInvalid}
placeholder="admin"
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
@@ -30,15 +30,15 @@ export default function AddUser({ users, isDisabled }: AddUserProps) {
Add this user to a list of allowed users that can authenticate with
Headscale via OIDC.
</Dialog.Text>
<input type="hidden" name="action_id" value="add_user" />
<input name="action_id" type="hidden" value="add_user" />
<Input
description="The user to allow for OIDC authentication."
isInvalid={user.trim().length === 0 || isInvalid}
isRequired
label="User"
description="The user to allow for OIDC authentication."
placeholder="john_doe"
name="user"
onChange={setUser}
isInvalid={user.trim().length === 0 || isInvalid}
placeholder="john_doe"
/>
{isInvalid && (
<p className="text-red-500 text-sm mt-2">
+13 -23
View File
@@ -1,24 +1,15 @@
import {
ActionFunctionArgs,
LoaderFunctionArgs,
Link as RemixLink,
data,
useLoaderData,
} from 'react-router';
import { data, Link as RemixLink } from 'react-router';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
import { restrictionAction } from './actions';
import AddDomain from './dialogs/add-domain';
import AddGroup from './dialogs/add-group';
import AddUser from './dialogs/add-user';
import RestrictionTable from './table';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) {
throw data('You do not have permission to view IAM settings.', {
@@ -43,19 +34,18 @@ export async function loader({
};
}
export async function action(request: ActionFunctionArgs) {
return restrictionAction(request);
}
export const action = restrictionAction;
export default function Page() {
const { access, writable, settings } = useLoaderData<typeof loader>();
export default function Page({
loaderData: { access, writable, settings },
}: Route.ComponentProps) {
const isDisabled = writable ? !access : true;
return (
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-2/3">
<p className="mb-4 text-md">
<RemixLink to="/settings" className="font-medium">
<RemixLink className="font-medium" to="/settings">
Settings
</RemixLink>
<span className="mx-2">/</span> Authentication Restrictions
@@ -85,33 +75,33 @@ export default function Page() {
used to limit access to your Tailnet to only certain users or groups
and Headplane will also respect these settings when authenticating.{' '}
<Link
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
>
Learn More
</Link>
</p>
</div>
<RestrictionTable
isDisabled={isDisabled}
type="domain"
values={settings.domains}
isDisabled={isDisabled}
>
<AddDomain domains={settings.domains} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable
isDisabled={isDisabled}
type="group"
values={settings.groups}
isDisabled={isDisabled}
>
<AddGroup groups={settings.groups} isDisabled={isDisabled} />
</RestrictionTable>
<RestrictionTable
isDisabled={isDisabled}
type="user"
values={settings.users}
isDisabled={isDisabled}
>
<AddUser users={settings.users} isDisabled={isDisabled} />
<AddUser isDisabled={isDisabled} users={settings.users} />
</RestrictionTable>
</div>
);
+4 -4
View File
@@ -40,18 +40,18 @@ export default function RestrictionTable({
)}
<Form method="POST">
<input
type="hidden"
name="action_id"
type="hidden"
value={`remove_${type}`}
/>
<input type="hidden" name={type} value={value} />
<input name={type} type="hidden" value={value} />
<Button
isDisabled={isDisabled}
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
+20 -43
View File
@@ -4,20 +4,11 @@ import { faker } from '@faker-js/faker';
import { eq } from 'drizzle-orm';
import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
ActionFunctionArgs,
data,
LinksFunction,
LoaderFunctionArgs,
ShouldRevalidateFunction,
useLoaderData,
useSubmit,
} from 'react-router';
import { data, type ShouldRevalidateFunction, useSubmit } from 'react-router';
import { ExternalScriptsHandle } from 'remix-utils/external-scripts';
import { LoadContext } from '~/server';
import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
import { Machine, PreAuthKey, User } from '~/types';
import { useLiveData } from '~/utils/live-data';
import type { Route } from './+types/console';
import UserPrompt from './user-prompt';
import XTerm from './xterm.client';
@@ -25,10 +16,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false;
};
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
const origin = new URL(request.url).origin;
const assets = ['/wasm_exec.js', '/hp_ssh.wasm'];
const missing: string[] = [];
@@ -53,10 +41,9 @@ export async function loader({
if (session.user.subject === 'unknown-non-oauth') {
throw data('Only OAuth users are allowed to use WebSSH', 403);
}
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
session.api_key,
);
const api = context.hsApi.getRuntimeClient(session.api_key);
const users = await api.getUsers();
// MARK: This assumes that a user has authenticated with Headscale first
// Since the only way to enforce permissions via ACLs is to generate a
@@ -77,15 +64,12 @@ export async function loader({
);
}
const { preAuthKey } = await context.client.post<{ preAuthKey: PreAuthKey }>(
'v1/preauthkey',
session.api_key,
{
user: lookup.id,
reusable: false,
ephemeral: true,
expiration: new Date(Date.now() + 60 * 1000).toISOString(), // 1 minute
},
const preAuthKey = await api.createPreAuthKey(
lookup.id,
true, // ephemeral
false, // reusable
new Date(Date.now() + 60 * 1000), // expiration: 1 minute
null, // aclTags
);
// TODO: Enable config to enforce generate_authkeys capability
@@ -130,13 +114,8 @@ export async function loader({
// );
// }
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.api_key,
);
// node.name is the hostname, given_name is the set name
const lookupNode = nodes.find((n) => n.name === hostname);
const nodes = await api.getNodes();
const lookupNode = nodes.find((n) => n.givenName === hostname);
if (!lookupNode) {
throw data(`Node with hostname ${hostname} not found`, 404);
}
@@ -182,11 +161,8 @@ function generateHostname(username: string) {
return `ssh-${adjective}-${noun}-${username}`;
}
export async function action({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const _session = await context.sessions.auth(request);
export async function action({ request, context }: Route.ActionArgs) {
await context.sessions.auth(request);
if (!context.agents?.agentID()) {
throw data(
'WebSSH is only available with the Headplane agent integration',
@@ -214,7 +190,7 @@ export async function action({
.where(eq(ephemeralNodes.auth_key, authKey));
}
export const links: LinksFunction = () => [
export const links: Route.LinksFunction = () => [
{
rel: 'preload',
href: '/hp_ssh.wasm',
@@ -234,13 +210,14 @@ export const handle: ExternalScriptsHandle = {
],
};
export default function Page() {
export default function Page({
loaderData: { ipnDetails, sshDetails },
}: Route.ComponentProps) {
const submit = useSubmit();
const { pause } = useLiveData();
const [ipn, setIpn] = useState<TsWasmNet | null>(null);
const [nodeKey, setNodeKey] = useState<string | null>(null);
const { ipnDetails, sshDetails } = useLoaderData<typeof loader>();
useEffect(() => {
if (!ipnDetails) {
@@ -1,17 +1,16 @@
import { Building2, House, Key } from 'lucide-react';
import Card from '~/components/Card';
import Link from '~/components/Link';
import type { HeadplaneConfig } from '~/server/config/schema';
import CreateUser from '../dialogs/create-user';
interface ManageBannerProps {
oidc?: NonNullable<HeadplaneConfig['oidc']>;
oidc?: { issuer: string };
isDisabled?: boolean;
}
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
return (
<Card variant="flat" className="mb-8 w-full max-w-full p-0">
<Card className="mb-8 w-full max-w-full p-0" variant="flat">
<div className="flex flex-col md:flex-row">
<div className="w-full p-4 border-b md:border-b-0 border-headplane-100 dark:border-headplane-800">
{oidc ? (
@@ -26,14 +25,14 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
{oidc ? (
<>
Users are managed through your{' '}
<Link to={oidc.issuer} name="OIDC Provider">
<Link name="OIDC Provider" to={oidc.issuer}>
OpenID Connect provider
</Link>
{'. '}
Groups and user information do not automatically sync.{' '}
<Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation"
to="https://headscale.net/stable/ref/oidc"
>
Learn more
</Link>
@@ -43,8 +42,8 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
Users are not managed externally. Using OpenID Connect can
create a better experience when using Headscale.{' '}
<Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation"
to="https://headscale.net/stable/ref/oidc"
>
Learn more
</Link>
@@ -61,7 +60,7 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
: 'You can add, remove, and rename users here.'}
</p>
<div className="flex items-center gap-2 mt-4">
<CreateUser isOidc={oidc !== undefined} isDisabled={isDisabled} />
<CreateUser isDisabled={isDisabled} isOidc={oidc !== undefined} />
</div>
</div>
</div>
+4 -4
View File
@@ -22,40 +22,40 @@ export default function UserMenu({ user }: MenuProps) {
<>
{modal === 'rename' && (
<Rename
user={user}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === 'delete' && (
<Delete
user={user}
isOpen={modal === 'delete'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
{modal === 'reassign' && (
<Reassign
user={user}
isOpen={modal === 'reassign'}
setIsOpen={(isOpen) => {
if (!isOpen) setModal(null);
}}
user={user}
/>
)}
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : ['reassign']}>
<Menu.IconButton
label="Machine Options"
className={cn(
'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700',
)}
label="Machine Options"
>
<Ellipsis className="h-5" />
</Menu.IconButton>
+7 -5
View File
@@ -18,22 +18,24 @@ export default function UserRow({ user, role }: UserRowProps) {
return (
<tr
key={user.id}
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
key={user.id}
>
<td className="pl-0.5 py-2">
<div className="flex items-center">
{user.profilePicUrl ? (
<img
src={user.profilePicUrl}
alt={user.name || user.displayName}
className="w-10 h-10 rounded-full"
src={user.profilePicUrl}
/>
) : (
<CircleUser className="w-10 h-10" />
)}
<div className="ml-4">
<p className={cn('font-semibold leading-snug')}>{user.name || user.displayName}</p>
<p className={cn('font-semibold leading-snug')}>
{user.name || user.displayName}
</p>
<p className="text-sm opacity-50">{user.email}</p>
</div>
</div>
@@ -43,8 +45,8 @@ export default function UserRow({ user, role }: UserRowProps) {
</td>
<td className="pl-0.5 py-2">
<p
suppressHydrationWarning
className="text-sm text-headplane-600 dark:text-headplane-300"
suppressHydrationWarning
>
{new Date(user.createdAt).toLocaleDateString()}
</p>
@@ -56,7 +58,7 @@ export default function UserRow({ user, role }: UserRowProps) {
'text-headplane-600 dark:text-headplane-300',
)}
>
<StatusCircle isOnline={isOnline} className="w-4 h-4" />
<StatusCircle className="w-4 h-4" isOnline={isOnline} />
<p suppressHydrationWarning>
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
</p>
+12 -12
View File
@@ -24,15 +24,14 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
</>
) : undefined}
</Dialog.Text>
<input type="hidden" name="action_id" value="create_user" />
<input name="action_id" type="hidden" value="create_user" />
<div className="flex flex-col gap-4">
<Input
isRequired
name="username"
type="text"
label="Username"
name="username"
placeholder="my-new-user"
validationBehavior="native"
type="text"
validate={(value) => {
if (value.trim().length === 0) {
return 'Username is required';
@@ -44,19 +43,20 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
return true;
}}
/>
<Input
name="display_name"
type="text"
label="Display Name"
placeholder="John Doe"
validationBehavior="native"
/>
<Input
name="email"
type="email"
label="Display Name"
name="display_name"
placeholder="John Doe"
type="text"
validationBehavior="native"
/>
<Input
label="Email"
name="email"
placeholder="name@example.com"
type="email"
validationBehavior="native"
/>
</div>
+2 -2
View File
@@ -32,8 +32,8 @@ export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
)}
</Dialog.Text>
)}
<input type="hidden" name="action_id" value="delete_user" />
<input type="hidden" name="user_id" value={user.id} />
<input name="action_id" type="hidden" value="delete_user" />
<input name="user_id" type="hidden" value={user.id} />
</Dialog.Panel>
</Dialog>
);
+10 -8
View File
@@ -21,14 +21,16 @@ export default function ReassignUser({
<Dialog.Panel
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
>
<Dialog.Title>Change role for {user.name || user.displayName}?</Dialog.Title>
<Dialog.Title>
Change role for {user.name || user.displayName}?
</Dialog.Title>
<Dialog.Text className="mb-6">
Most roles are carried straight from Tailscale. However, keep in mind
that I have not fully implemented permissions yet and some things may
be accessible to everyone. The only fully completed role is Member.{' '}
<Link
to="https://tailscale.com/kb/1138/user-roles"
name="Tailscale User Roles documentation"
to="https://tailscale.com/kb/1138/user-roles"
>
Learn More
</Link>
@@ -37,21 +39,21 @@ export default function ReassignUser({
<Notice>The Tailnet owner cannot be reassigned.</Notice>
) : (
<>
<input type="hidden" name="action_id" value="reassign_user" />
<input type="hidden" name="user_id" value={user.id} />
<input name="action_id" type="hidden" value="reassign_user" />
<input name="user_id" type="hidden" value={user.id} />
<RadioGroup
isRequired
name="new_role"
label="Role"
className="gap-4"
defaultValue={user.headplaneRole}
isRequired
label="Role"
name="new_role"
>
{Object.keys(Roles)
.filter((role) => role !== 'owner')
.map((role) => {
const { name, desc } = mapRoleToName(role);
return (
<RadioGroup.Radio key={role} value={role} label={name}>
<RadioGroup.Radio key={role} label={name} value={role}>
<div className="block">
<p className="font-bold">{name}</p>
<p className="opacity-70">{desc}</p>
+9 -9
View File
@@ -15,18 +15,18 @@ export default function RenameUser({ user, isOpen, setIsOpen }: RenameProps) {
<Dialog.Panel>
<Dialog.Title>Rename {user.name || user.displayName}?</Dialog.Title>
<Dialog.Text className="mb-6">
Enter a new username for {user.name || user.displayName}. Changing a username will not
update any ACL policies that may refer to this user by their old
username.
Enter a new username for {user.name || user.displayName}. Changing a
username will not update any ACL policies that may refer to this user
by their old username.
</Dialog.Text>
<input type="hidden" name="action_id" value="rename_user" />
<input type="hidden" name="user_id" value={user.id} />
<input name="action_id" type="hidden" value="rename_user" />
<input name="user_id" type="hidden" value={user.id} />
<Input
isRequired
name="new_name"
label="Username"
placeholder="my-new-name"
defaultValue={user.name}
isRequired
label="Username"
name="new_name"
placeholder="my-new-name"
/>
</Dialog.Panel>
</Dialog>
+3 -6
View File
@@ -1,12 +1,9 @@
import { eq } from 'drizzle-orm';
import { LoaderFunctionArgs, redirect } from 'react-router';
import { LoadContext } from '~/server';
import { redirect } from 'react-router';
import { users } from '~/server/db/schema';
import type { Route } from './+types/onboarding-skip';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
try {
const { user } = await context.sessions.auth(request);
await context.db
+8 -13
View File
@@ -1,23 +1,20 @@
import { Icon } from '@iconify/react';
import { ArrowRight } from 'lucide-react';
import { useEffect } from 'react';
import { LoaderFunctionArgs, NavLink, useLoaderData } from 'react-router';
import { NavLink } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Link from '~/components/Link';
import Options from '~/components/Options';
import StatusCircle from '~/components/StatusCircle';
import { LoadContext } from '~/server';
import { Machine } from '~/types';
import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data';
import log from '~/utils/log';
import toast from '~/utils/toast';
import type { Route } from './+types/onboarding';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
@@ -48,13 +45,10 @@ export async function loader({
break;
}
const api = context.hsApi.getRuntimeClient(session.api_key);
let firstMachine: Machine | undefined;
try {
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.api_key,
);
const nodes = await api.getNodes();
const node = nodes.find((n) => {
if (n.user.provider !== 'oidc') {
return false;
@@ -87,8 +81,9 @@ export async function loader({
};
}
export default function Page() {
const { user, osValue, firstMachine } = useLoaderData<typeof loader>();
export default function Page({
loaderData: { user, osValue, firstMachine },
}: Route.ComponentProps) {
const { pause, resume } = useLiveData();
useEffect(() => {
if (firstMachine) {
+19 -25
View File
@@ -1,10 +1,8 @@
import { useEffect, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles';
import { Machine, User } from '~/types';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn';
import type { Route } from './+types/overview';
import ManageBanner from './components/manage-banner';
import UserRow from './components/user-row';
import { userAction } from './user-actions';
@@ -13,10 +11,7 @@ interface UserMachine extends User {
machines: Machine[];
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) {
@@ -31,14 +26,12 @@ export async function loader({
Capabilities.write_users,
);
const [machines, apiUsers] = await Promise.all([
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key),
context.client.get<{ users: User[] }>('v1/user', session.api_key),
]);
const api = context.hsApi.getRuntimeClient(session.api_key);
const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
const users = apiUsers.users.map((user) => ({
const users = apiUsers.map((user) => ({
...user,
machines: machines.nodes.filter((machine) => machine.user.id === user.id),
machines: nodes.filter((node) => node.user.id === user.id),
}));
const roles = await Promise.all(
@@ -77,28 +70,29 @@ export async function loader({
return {
writable: writablePermission, // whether the user can write to the API
oidc: context.config.oidc,
oidc: context.config.oidc
? {
issuer: context.config.oidc.issuer,
}
: undefined,
roles,
magic,
users,
};
}
export async function action(data: ActionFunctionArgs) {
return userAction(data);
}
export const action = userAction;
export default function Page() {
const data = useLoaderData<typeof loader>();
const [users, setUsers] = useState<UserMachine[]>(data.users);
export default function Page({ loaderData }: Route.ComponentProps) {
const [users, setUsers] = useState<UserMachine[]>(loaderData.users);
// This useEffect is entirely for the purpose of updating the users when the
// drag and drop changes the machines between users. It's pretty hacky, but
// the idea is to treat data.users as the source of truth and update the
// local state when it changes.
useEffect(() => {
setUsers(data.users);
}, [data.users]);
setUsers(loaderData.users);
}, [loaderData.users]);
return (
<>
@@ -106,7 +100,7 @@ export default function Page() {
<p className="mb-8 text-md">
Manage the users in your network and their permissions.
</p>
<ManageBanner isDisabled={!data.writable} oidc={data.oidc} />
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
<table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
@@ -127,7 +121,7 @@ export default function Page() {
.map((user) => (
<UserRow
key={user.id}
role={data.roles[users.indexOf(user)]}
role={loaderData.roles[users.indexOf(user)]}
user={user}
/>
))}
+99 -124
View File
@@ -1,140 +1,115 @@
import { ActionFunctionArgs, data } from 'react-router';
import type { LoadContext } from '~/server';
import { data } from 'react-router';
import { Capabilities, Roles } from '~/server/web/roles';
import { User } from '~/types';
import { data400, data403 } from '~/utils/res';
import type { Route } from './+types/overview';
export async function userAction({
request,
context,
}: ActionFunctionArgs<LoadContext>) {
export async function userAction({ request, context }: Route.ActionArgs) {
const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.write_users);
if (!check) {
throw data403('You do not have permission to update users');
throw data('You do not have permission to update users', {
status: 403,
});
}
const apiKey = session.api_key;
const formData = await request.formData();
const action = formData.get('action_id')?.toString();
if (!action) {
throw data400('Missing `action_id` in the form data.');
throw data('Missing `action_id` in the form data.', {
status: 404,
});
}
const api = context.hsApi.getRuntimeClient(session.api_key);
switch (action) {
case 'create_user':
return createUser(formData, apiKey, context);
case 'delete_user':
return deleteUser(formData, apiKey, context);
case 'rename_user':
return renameUser(formData, apiKey, context);
case 'reassign_user':
return reassignUser(formData, apiKey, context);
case 'create_user': {
const name = formData.get('username')?.toString();
const displayName = formData.get('display_name')?.toString();
const email = formData.get('email')?.toString();
if (!name) {
throw data('Missing `username` in the form data.', {
status: 400,
});
}
await api.createUser(name, email, displayName);
return { message: 'User created successfully' };
}
case 'delete_user': {
const userId = formData.get('user_id')?.toString();
if (!userId) {
throw data('Missing `user_id` in the form data.', {
status: 400,
});
}
await api.deleteUser(userId);
return { message: 'User deleted successfully' };
}
case 'rename_user': {
const userId = formData.get('user_id')?.toString();
const newName = formData.get('new_name')?.toString();
if (!userId || !newName) {
return data({ success: false }, 400);
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data(`No user found with id: ${userId}`, { status: 400 });
}
if (user.provider === 'oidc') {
// OIDC users cannot be renamed via this endpoint, return an error
throw data('Users managed by OIDC cannot be renamed', {
status: 403,
});
}
await api.renameUser(userId, newName);
return { message: 'User renamed successfully' };
}
case 'reassign_user': {
const userId = formData.get('user_id')?.toString();
const newRole = formData.get('new_role')?.toString();
if (!userId || !newRole) {
throw data('Missing `user_id` or `new_role` in the form data.', {
status: 400,
});
}
const users = await api.getUsers(userId);
const user = users.find((user) => user.id === userId);
if (!user?.providerId) {
throw data('Specified user is not an OIDC user', {
status: 400,
});
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = user.providerId?.split('/').pop();
if (!subject) {
throw data(
'Malformed `providerId` for the specified user. Cannot find subject.',
{ status: 400 },
);
}
const result = await context.sessions.reassignSubject(
subject,
newRole as keyof typeof Roles,
);
if (!result) {
throw data('Failed to reassign user role.', { status: 500 });
}
return { message: 'User reassigned successfully' };
}
default:
throw data400('Invalid `action_id` provided.');
throw data('Invalid `action_id` provided.', {
status: 400,
});
}
}
async function createUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const name = formData.get('username')?.toString();
const displayName = formData.get('display_name')?.toString();
const email = formData.get('email')?.toString();
if (!name) {
throw data400('Missing `username` in the form data.');
}
await context.client.post('v1/user', apiKey, {
name,
displayName,
email,
});
}
async function deleteUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const userId = formData.get('user_id')?.toString();
if (!userId) {
throw data400('Missing `user_id` in the form data.');
}
await context.client.delete(`v1/user/${userId}`, apiKey);
}
async function renameUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const userId = formData.get('user_id')?.toString();
const newName = formData.get('new_name')?.toString();
if (!userId || !newName) {
return data({ success: false }, 400);
}
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
apiKey,
);
const user = users.find((user) => user.id === userId);
if (!user) {
throw data400(`No user found with id: ${userId}`);
}
if (user.provider === 'oidc') {
// OIDC users cannot be renamed via this endpoint, return an error
throw data403('Users managed by OIDC cannot be renamed');
}
await context.client.post(`v1/user/${userId}/rename/${newName}`, apiKey);
}
async function reassignUser(
formData: FormData,
apiKey: string,
context: LoadContext,
) {
const userId = formData.get('user_id')?.toString();
const newRole = formData.get('new_role')?.toString();
if (!userId || !newRole) {
throw data400('Missing `user_id` or `new_role` in the form data.');
}
const { users } = await context.client.get<{ users: User[] }>(
'v1/user',
apiKey,
);
const user = users.find((user) => user.id === userId);
if (!user?.providerId) {
throw data400('Specified user is not an OIDC user');
}
// For some reason, headscale makes providerID a url where the
// last component is the subject, so we need to strip that out
const subject = user.providerId?.split('/').pop();
if (!subject) {
throw data400(
'Malformed `providerId` for the specified user. Cannot find subject.',
);
}
const result = await context.sessions.reassignSubject(
subject,
newRole as keyof typeof Roles,
);
if (!result) {
return data({ success: false }, 403);
}
return data({ success: true });
}
+6 -4
View File
@@ -1,8 +1,10 @@
import { LoaderFunctionArgs } from 'react-router';
import type { LoadContext } from '~/server';
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();
export async function loader({ context }: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck();
return new Response(JSON.stringify({ status: healthy ? 'OK' : 'ERROR' }), {
status: healthy ? 200 : 500,
headers: {
-33
View File
@@ -1,33 +0,0 @@
# Headplane Server
This code is responsible for all code that is necessary *before* any
web server is started. It is the only part of the code that contains
many side-effects (in this case, importing a module may run code).
# Hierarchy
```
server
├── index.ts: Loads everything and starts the web server.
├── agent/
│ ├── dispatcher.ts: Serializes commands for the agent control fd (stdin).
│ ├── ssh.ts: Manages & multiplexes the active web SSH connections
│ ├── env.ts: Checks the environment variables for custom overrides.
├── config/
│ ├── integration/
│ │ ├── abstract.ts: Defines the abstract class for integrations.
│ │ ├── docker.ts: Contains the Docker integration.
│ │ ├── index.ts: Determines the correct integration to use (if any).
│ │ ├── kubernetes.ts: Contains the Kubernetes integration.
│ │ ├── proc.ts: Contains the Proc integration.
│ ├── env.ts: Checks the environment variables for custom overrides.
│ ├── loader.ts: Checks the configuration file and coalesces with ENV.
│ ├── schema.ts: Defines the schema for the Headplane configuration.
├── headscale/
│ ├── api-client.ts: Creates the HTTP client that talks to the Headscale API.
│ ├── api-error.ts: Contains the ResponseError definition.
│ ├── config-loader.ts: Loads the Headscale configuration (if available).
│ ├── config-schema.ts: Defines the schema for the Headscale configuration.
├── web/
│ ├── agent.ts: Handles setting up the agent WebSocket if needed.
│ ├── oidc.ts: Loads and validates an OIDC configuration (if available).
│ ├── roles.ts: Contains information about authentication permissions.
│ ├── sessions.ts: Initializes the session store and methods to manage it.
+177
View File
@@ -0,0 +1,177 @@
import { type } from 'arktype';
import DockerIntegration from './integration/docker';
import KubernetesIntegration from './integration/kubernetes';
import ProcIntegration from './integration/proc';
import { deprecatedField } from './utils';
export const pathSupportedKeys = [
'server.cookie_secret',
'oidc.client_secret',
'oidc.headscale_api_key',
'integration.agent.pre_authkey',
] as const;
const serverConfig = type({
host: 'string.ip = "0.0.0.0"',
port: 'number.integer = 3000',
data_path: 'string.lower = "/var/lib/headplane/"',
cookie_secret: '(32 <= string <= 32)',
cookie_secure: 'boolean = true',
cookie_domain: 'string.lower?',
cookie_max_age: 'number.integer = 86400',
});
const partialServerConfig = type({
host: 'string.ip?',
port: 'number.integer?',
data_path: 'string.lower?',
cookie_secret: '(32 <= string <= 32)?',
cookie_secure: 'boolean?',
cookie_domain: 'string.lower?',
cookie_max_age: 'number.integer?',
});
const headscaleConfig = type({
url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)),
public_url: type('string.url')
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
.optional(),
config_path: 'string.lower?',
config_strict: 'boolean = true',
dns_records_path: 'string.lower?',
tls_cert_path: 'string.lower?',
});
const partialHeadscaleConfig = type({
url: type('string.url')
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
.optional(),
public_url: type('string.url')
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
.optional(),
config_path: 'string.lower?',
config_strict: 'boolean?',
dns_records_path: 'string.lower?',
tls_cert_path: 'string.lower?',
});
const oidcConfig = type({
issuer: 'string.url',
client_id: 'string',
client_secret: 'string',
headscale_api_key: 'string',
redirect_uri: 'string.url?',
disable_api_key_login: 'boolean = false',
scope: 'string = "openid email profile"',
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
extra_params: 'Record<string, string>?',
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
// Old/deprecated options
user_storage_file: 'string.lower = "/var/lib/headplane/users.json"',
strict_validation: type('unknown').narrow(deprecatedField()).optional(),
token_endpoint_auth_method: type('unknown')
.narrow(deprecatedField())
.optional(),
});
const partialOidcConfig = type({
issuer: 'string.url?',
client_id: 'string?',
client_secret: 'string?',
headscale_api_key: 'string?',
redirect_uri: 'string.url?',
disable_api_key_login: 'boolean?',
scope: 'string?',
extra_params: 'Record<string, string>?',
profile_picture_source: '"oidc" | "gravatar"?',
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
// Old/deprecated options
user_storage_file: 'string.lower?',
strict_validation: type('unknown').narrow(deprecatedField()).optional(),
token_endpoint_auth_method: type('unknown')
.narrow(deprecatedField())
.optional(),
});
const agentConfig = type({
enabled: 'boolean',
host_name: 'string = "headplane-agent"',
pre_authkey: 'string',
cache_ttl: 'number.integer = 180000',
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
executable_path: 'string = "/usr/libexec/headplane/agent"',
work_dir: 'string = "/var/lib/headplane/agent"',
});
const partialAgentConfig = type({
enabled: 'boolean?',
host_name: 'string?',
pre_authkey: 'string?',
cache_ttl: 'number.integer?',
cache_path: 'string?',
executable_path: 'string?',
work_dir: 'string?',
});
const integrationConfig = type({
docker: DockerIntegration.configSchema.full,
kubernetes: KubernetesIntegration.configSchema.full,
proc: ProcIntegration.configSchema.full,
agent: agentConfig.optional(),
}).partial();
export const partialIntegrationConfig = type({
docker: DockerIntegration.configSchema.partial,
kubernetes: KubernetesIntegration.configSchema.partial,
proc: ProcIntegration.configSchema.partial,
agent: partialAgentConfig.optional(),
}).partial();
export const headplaneConfig = type({
debug: 'boolean = false',
server: serverConfig,
headscale: headscaleConfig,
oidc: oidcConfig.optional(),
integration: integrationConfig.optional(),
}).onDeepUndeclaredKey('delete');
export const partialHeadplaneConfig = type({
debug: 'boolean?',
server: partialServerConfig.optional(),
headscale: partialHeadscaleConfig.optional(),
oidc: partialOidcConfig.optional(),
integration: partialIntegrationConfig.optional(),
});
export type HeadplaneConfig = typeof headplaneConfig.infer;
export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer;
type DotNotationToObjects<
T extends string,
V,
> = T extends `${infer K}.${infer Rest}`
? { [P in K]?: DotNotationToObjects<Rest, V> }
: { [P in `${T}_path`]?: V };
type ObjectDeepMerge<T> = T extends object
? {
[K in keyof T]: T[K] extends object ? ObjectDeepMerge<T[K]> : T[K];
}
: T;
type ConfigWithPathKeys = ObjectDeepMerge<
DotNotationToObjects<(typeof pathSupportedKeys)[number], string | undefined>
>;
export type PartialHeadplaneConfigWithPaths = PartialHeadplaneConfig &
ConfigWithPathKeys;
-76
View File
@@ -1,76 +0,0 @@
import { exit } from 'node:process';
import { type } from 'arktype';
import log from '~/utils/log';
// Custom type for boolean environment variables, allowing for values like
// 1, true, yes, and on to count as a truthy value.
const booleanEnv = type('string | undefined').pipe((v) => {
return ['1', 'true', 'yes', 'on'].includes(v?.toLowerCase() ?? '');
});
export const envVariables = {
debugLog: 'HEADPLANE_DEBUG_LOG',
envOverrides: 'HEADPLANE_LOAD_ENV_OVERRIDES',
configPath: 'HEADPLANE_CONFIG_PATH',
} as const;
export function configureLogger(env: string | undefined) {
const result = booleanEnv(env);
if (result instanceof type.errors) {
log.error(
'config',
'HEADPLANE_DEBUG_LOG value is invalid: %s',
result.summary,
);
log.info('config', 'Using a default value: false');
log.debug = () => {}; // Disable debug logging if the value is invalid
log.debugEnabled = false;
return;
}
if (result === false) {
log.debug = () => {}; // Disable debug logging if the value is false
log.debugEnabled = false;
return;
}
log.debug('config', 'Debug logging has been enabled');
log.debug('config', 'It is recommended this be disabled in production');
}
export interface EnvOverrides {
loadEnv: boolean;
path: string;
}
export function configureConfig(overrides: {
loadEnv: string | undefined;
path: string | undefined;
}): EnvOverrides {
const loadResult = booleanEnv(overrides.loadEnv);
if (loadResult instanceof type.errors) {
log.error(
'config',
'HEADPLANE_LOAD_ENV_OVERRIDES value is invalid: %s',
loadResult.summary,
);
exit(1);
}
const pathResult = type('string | undefined')(overrides.path);
if (pathResult instanceof type.errors) {
log.error(
'config',
'HEADPLANE_CONFIG_PATH value is invalid: %s',
pathResult.summary,
);
exit(1);
}
return {
loadEnv: loadResult,
path: pathResult ?? '/etc/headplane/config.yaml',
};
}
+74
View File
@@ -0,0 +1,74 @@
interface ErrorCodes {
CONFLICTING_SECRET_PATH_FIELD: {
fieldName: string;
};
INVALID_REQUIRED_FIELDS: {
messages: string[];
};
MISSING_INTERPOLATION_VARIABLE: {
pathKey: string;
variableName: string;
};
MISSING_SECRET_FILE: {
pathKey: string;
filePath: string;
};
}
const translationsWithVars: {
[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}".`,
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;
/**
* 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);
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
import type { ApiClient } from '~/server/headscale/api-client';
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
export abstract class Integration<T> {
protected context: NonNullable<T>;
@@ -11,6 +11,6 @@ export abstract class Integration<T> {
}
abstract isAvailable(): Promise<boolean> | boolean;
abstract onConfigChange(client: ApiClient): Promise<void> | void;
abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void;
abstract get name(): string;
}
+36 -15
View File
@@ -1,9 +1,9 @@
import { constants, access } from 'node:fs/promises';
import { access, constants } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import { type } from 'arktype';
import { Client } from 'undici';
import { ApiClient } from '~/server/headscale/api-client';
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
import log from '~/utils/log';
import type { HeadplaneConfig } from '../schema';
import { Integration } from './abstract';
interface DockerContainer {
@@ -11,8 +11,25 @@ interface DockerContainer {
Names: string[];
}
type T = NonNullable<HeadplaneConfig['integration']>['docker'];
export default class DockerIntegration extends Integration<T> {
const configSchema = {
full: type({
enabled: 'boolean',
container_name: 'string?',
container_label: 'string = "me.tale.headplane.target=headscale"',
socket: 'string = "unix:///var/run/docker.sock"',
}),
partial: type({
enabled: 'boolean?',
container_name: 'string?',
container_label: 'string?',
socket: 'string?',
}).partial(),
};
export default class DockerIntegration extends Integration<
typeof configSchema.full.infer
> {
private maxAttempts = 10;
private client: Client | undefined;
private containerId: string | undefined;
@@ -21,6 +38,10 @@ export default class DockerIntegration extends Integration<T> {
return 'Docker';
}
static get configSchema() {
return configSchema;
}
async getContainerName(label: string, value: string): Promise<string> {
if (!this.client) {
throw new Error('Docker client is not initialized');
@@ -60,7 +81,7 @@ export default class DockerIntegration extends Integration<T> {
// Basic configuration check, the name overrides the container_label
// selector because of legacy support.
const { container_name, container_label } = this.context;
if (container_name.length === 0 && container_label.length === 0) {
if (container_name?.length === 0 && container_label.length === 0) {
log.error(
'config',
'Missing a Docker `container_name` or `container_label`',
@@ -94,7 +115,7 @@ export default class DockerIntegration extends Integration<T> {
try {
log.info('config', 'Checking API: %s', fetchU);
await fetch(new URL('/v1.30/version', fetchU).href);
await fetch(new URL('/v1.44/version', fetchU).href);
} catch (error) {
log.error('config', 'Failed to connect to Docker API: %s', error);
log.debug('config', 'Connection error: %o', error);
@@ -127,7 +148,7 @@ export default class DockerIntegration extends Integration<T> {
const qp = new URLSearchParams({
filters: JSON.stringify(
container_name.length > 0
container_name != null && container_name.length > 0
? { name: [container_name] }
: { label: [container_label] },
),
@@ -140,7 +161,7 @@ export default class DockerIntegration extends Integration<T> {
);
const res = await this.client.request({
method: 'GET',
path: `/v1.30/containers/json?${qp.toString()}`,
path: `/v1.44/containers/json?${qp.toString()}`,
});
if (res.statusCode !== 200) {
@@ -151,7 +172,7 @@ export default class DockerIntegration extends Integration<T> {
const data = (await res.body.json()) as DockerContainer[];
if (data.length > 1) {
if (container_name.length > 0) {
if (container_name != null && container_name.length > 0) {
log.error(
'config',
`Found multiple containers with name ${container_name}`,
@@ -167,7 +188,7 @@ export default class DockerIntegration extends Integration<T> {
}
if (data.length === 0) {
if (container_name.length > 0) {
if (container_name != null && container_name.length > 0) {
log.error(
'config',
`No container found with the name ${container_name}`,
@@ -193,7 +214,7 @@ export default class DockerIntegration extends Integration<T> {
return this.client !== undefined && this.containerId !== undefined;
}
async onConfigChange(client: ApiClient) {
async onConfigChange(client: RuntimeApiClient) {
if (!this.client) {
return;
}
@@ -211,7 +232,7 @@ export default class DockerIntegration extends Integration<T> {
const response = await this.client.request({
method: 'POST',
path: `/v1.30/containers/${this.containerId}/restart`,
path: `/v1.44/containers/${this.containerId}/restart`,
});
if (response.statusCode !== 204) {
@@ -233,14 +254,14 @@ export default class DockerIntegration extends Integration<T> {
while (attempts <= this.maxAttempts) {
try {
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck();
const status = await client.isHealthy();
if (status === false) {
throw new Error('Headscale is not running');
}
log.info('config', 'Headscale is up and running');
return;
} catch (error) {
} catch {
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
+4 -4
View File
@@ -1,5 +1,5 @@
import { HeadplaneConfig } from '~/server/config/schema';
import log from '~/utils/log';
import type { HeadplaneConfig } from '../config-schema';
import dockerIntegration from './docker';
import kubernetesIntegration from './kubernetes';
import procIntegration from './proc';
@@ -47,16 +47,16 @@ function getIntegration(integration: HeadplaneConfig['integration']) {
if (docker?.enabled) {
log.info('config', 'Using Docker integration');
return new dockerIntegration(integration?.docker);
return new dockerIntegration(integration!.docker!);
}
if (k8s?.enabled) {
log.info('config', 'Using Kubernetes integration');
return new kubernetesIntegration(integration?.kubernetes);
return new kubernetesIntegration(integration!.kubernetes!);
}
if (proc?.enabled) {
log.info('config', 'Using Proc integration');
return new procIntegration(integration?.proc);
return new procIntegration(integration!.proc!);
}
}
+27 -8
View File
@@ -1,12 +1,12 @@
import { readFile, readdir } from 'node:fs/promises';
import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os';
import { join, resolve } from 'node:path';
import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node';
import { ApiClient } from '~/server/headscale/api-client';
import { type } from 'arktype';
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
import log from '~/utils/log';
import { HeadplaneConfig } from '../schema';
import { Integration } from './abstract';
// https://github.com/kubernetes-client/javascript/blob/055b83c6504dfd1b2a2d081efd974163c6cbb808/src/config.ts#L40
@@ -15,8 +15,23 @@ const svcCaPath = `${svcRoot}/ca.crt`;
const svcTokenPath = `${svcRoot}/token`;
const svcNamespacePath = `${svcRoot}/namespace`;
type T = NonNullable<HeadplaneConfig['integration']>['kubernetes'];
export default class KubernetesIntegration extends Integration<T> {
const configSchema = {
full: type({
enabled: 'boolean',
pod_name: 'string',
validate_manifest: 'boolean = true',
}),
partial: type({
enabled: 'boolean?',
pod_name: 'string?',
validate_manifest: 'boolean?',
}).partial(),
};
export default class KubernetesIntegration extends Integration<
typeof configSchema.full.infer
> {
private pid: number | undefined;
private maxAttempts = 10;
@@ -24,6 +39,10 @@ export default class KubernetesIntegration extends Integration<T> {
return 'Kubernetes (k8s)';
}
static get configSchema() {
return configSchema;
}
async isAvailable() {
if (platform() !== 'linux') {
log.error('config', 'Kubernetes is only available on Linux');
@@ -202,7 +221,7 @@ export default class KubernetesIntegration extends Integration<T> {
}
}
async onConfigChange(client: ApiClient) {
async onConfigChange(client: RuntimeApiClient) {
if (!this.pid) {
return;
}
@@ -220,14 +239,14 @@ export default class KubernetesIntegration extends Integration<T> {
while (attempts <= this.maxAttempts) {
try {
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck();
const status = await client.isHealthy();
if (status === false) {
throw new Error('Headscale is not running');
}
log.info('config', 'Headscale is up and running');
return;
} catch (error) {
} catch {
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
+53 -30
View File
@@ -1,15 +1,27 @@
import { readFile, readdir } from 'node:fs/promises';
import { readdir, readFile } from 'node:fs/promises';
import { platform } from 'node:os';
import { join, resolve } from 'node:path';
import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises';
import { ApiClient } from '~/server/headscale/api-client';
import { type } from 'arktype';
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
import log from '~/utils/log';
import { HeadplaneConfig } from '../schema';
import type { HeadplaneConfig } from '../config-schema';
import { Integration } from './abstract';
type T = NonNullable<HeadplaneConfig['integration']>['proc'];
export default class ProcIntegration extends Integration<T> {
const configSchema = {
full: type({
enabled: 'boolean',
}),
partial: type({
enabled: 'boolean?',
}).partial(),
};
export default class ProcIntegration extends Integration<
typeof configSchema.full.infer
> {
private pid: number | undefined;
private maxAttempts = 10;
@@ -17,6 +29,10 @@ export default class ProcIntegration extends Integration<T> {
return 'Native Linux (/proc)';
}
static get configSchema() {
return configSchema;
}
async isAvailable() {
if (platform() !== 'linux') {
log.error('config', '/proc is only available on Linux');
@@ -68,30 +84,37 @@ export default class ProcIntegration extends Integration<T> {
pids.join(', '),
);
log.debug('config', 'Checking if any of them have Parent PID = 1, assuming thats the correct PID');
const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/;
for (const pid of pids) {
const pidStatusPath = join('/proc', pid.toString(), 'status');
try {
log.debug('config', 'Reading %s', pidStatusPath);
const pidData = await readFile(pidStatusPath, 'utf8');
const ppidResult = pidData.match(ppidRegex);
log.debug(
'config',
'Checking if any of them have Parent PID = 1, assuming thats the correct PID',
);
const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/;
for (const pid of pids) {
const pidStatusPath = join('/proc', pid.toString(), 'status');
try {
log.debug('config', 'Reading %s', pidStatusPath);
const pidData = await readFile(pidStatusPath, 'utf8');
const ppidResult = pidData.match(ppidRegex);
if (ppidResult !== null) {
const potentialPPid = Number.parseInt(ppidResult[1], 10);
if (potentialPPid === 1) {
this.pid = pid;
log.info('config', 'Found potential Headscale process with PID: %d based on Parent PID = 1', this.pid);
return true;
}
}
} catch (error) {
log.error('config', 'Failed to read %s: %s', pidStatusPath, error);
}
}
if (ppidResult !== null) {
const potentialPPid = Number.parseInt(ppidResult[1], 10);
if (potentialPPid === 1) {
this.pid = pid;
log.info(
'config',
'Found potential Headscale process with PID: %d based on Parent PID = 1',
this.pid,
);
return true;
}
}
} catch (error) {
log.error('config', 'Failed to read %s: %s', pidStatusPath, error);
}
}
return false;
}
return false;
}
if (pids.length === 0) {
log.error('config', 'Could not find Headscale process');
@@ -107,7 +130,7 @@ export default class ProcIntegration extends Integration<T> {
}
}
async onConfigChange(client: ApiClient) {
async onConfigChange(client: RuntimeApiClient) {
if (!this.pid) {
return;
}
@@ -125,7 +148,7 @@ export default class ProcIntegration extends Integration<T> {
while (attempts <= this.maxAttempts) {
try {
log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck();
const status = await client.isHealthy();
if (status === false) {
log.error('config', 'Headscale is not running');
return;
@@ -133,7 +156,7 @@ export default class ProcIntegration extends Integration<T> {
log.info('config', 'Headscale is up and running');
return;
} catch (error) {
} catch {
if (attempts < this.maxAttempts) {
attempts++;
await setTimeout(1000);
+267
View File
@@ -0,0 +1,267 @@
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';
/**
* Main entrypoint that attempts to load and merge configuration from both
* a YAML config file (if available) and environment variables. Importantly,
* the environment variables will override any values set in the config file.
*
* The function also supports loading secret values from file paths for
* specific configuration keys (e.g., certificates, private keys) by checking
* for corresponding `_path` suffixed environment variables or config file
* entries.
*
* @param configPathOverride Used for testing to override the config file path
* @returns @ref{HeadplaneConfig} The fully validated configuration
* @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 fileConfig = await loadConfigFile(configPath);
const envConfig = await loadConfigEnv();
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()),
});
}
return finalConfig;
}
/**
* Attempts to load configuration from a YAML file at the specified path.
* If the file is not accessible, it returns undefined.
*
* @param path The file path to load the configuration from
* @returns A partial configuration object or undefined
* @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;
}
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;
}
/**
* Loads configuration overrides from environment variables prefixed with
* `HEADPLANE_`. Nested configuration keys can be represented using double
* underscores (`__`). For example, `HEADPLANE_SERVER__PORT=8080` would set
* the `server.port` configuration key to `8080`.
*
* @returns A partial configuration object or undefined
* @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',
);
}
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 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;
}
/**
* Deeply merges multiple objects together. Later objects in the arguments
* list will override properties of earlier objects.
*
* @param objects The objects to merge
* @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;
}
}
}
return result as T;
}
/**
* Sets a value deeply within an object based on the provided path.
*
* @param obj The object to set the value in
* @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] = {};
}
current = current[key] as { [key: string]: unknown };
}
current[path[path.length - 1]] = value;
}
/**
* Parses an environment variable string value into an appropriate type.
* Supports booleans, null, undefined, and numbers. Falls back to string.
*
* @param value The environment variable string value
* @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;
if (/^-?\d+(\.\d+)?$/.test(v)) {
const num = Number(v);
if (!Number.isNaN(num)) return num;
}
return value;
}
/**
* For configuration keys that support loading from file paths (e.g.,
* certificates, private keys), this function checks for corresponding
* `_path` suffixed keys and loads the file content if the main key is
* not already set.
*
* @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('.'));
if (pathValue == null || typeof pathValue !== 'string') {
continue;
}
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,
});
}
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,
});
}
}
}
/**
* Deeply retrieves a value from an object based on the provided path.
*
* @param obj The object to retrieve the value from
* @param path An array of keys representing the path to retrieve
* @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;
}
current = current[segment] as { [key: string]: unknown };
}
return current;
}
-303
View File
@@ -1,303 +0,0 @@
import { access, constants, readFile } from 'node:fs/promises';
import { env } from 'node:process';
import { type } from 'arktype';
import { configDotenv } from 'dotenv';
import { parseDocument } from 'yaml';
import log from '~/utils/log';
import { EnvOverrides, envVariables } from './env';
import {
HeadplaneConfig,
headplaneConfig,
partialHeadplaneConfig,
} from './schema';
// Custom error for config issues
export class ConfigError extends Error {
constructor(message: string) {
super(message);
this.name = 'ConfigError';
}
}
/**
* Interpolate environment variables in a string
* Replaces ${VAR_NAME} patterns with the actual environment variable values
*/
export function interpolateEnvVars(str: string): string {
return str.replace(/\$\{([^}]+)\}/g, (_, varName) => {
const value = env[varName];
if (value === undefined) {
throw new ConfigError(`Environment variable "${varName}" not found`);
}
return value;
});
}
// loadConfig is a has a lifetime of the entire application and is
// used to load the configuration for Headplane. It is called once.
//
// TODO: Potential for file watching on the configuration
// But this may not be necessary as a use-case anyways
export async function loadConfig({ loadEnv, path }: EnvOverrides) {
log.debug('config', 'Loading configuration file: %s', path);
await validateConfigPath(path);
const data = await loadConfigFile(path);
if (!data) {
throw new ConfigError('Failed to load configuration file');
}
let config = validateConfig({ ...data, debug: log.debugEnabled });
if (!loadEnv) {
log.debug('config', 'Environment variable overrides are disabled');
log.debug('config', 'This also disables the loading of a .env file');
const moddedConfig = await loadSecretsFromFiles(config);
log.debug('config', 'Loaded file-based secrets');
return moddedConfig;
}
log.info('config', 'Loading a .env file (if available)');
configDotenv({ override: true, quiet: true });
const merged = coalesceEnv(config);
if (merged) config = merged;
if (config.headscale && typeof config.headscale.config_path === 'string') {
config.headscale.config_path = interpolateEnvVars(
config.headscale.config_path,
);
}
const moddedConfig = await loadSecretsFromFiles(config);
log.debug('config', 'Loaded file-based secrets');
return moddedConfig;
}
/**
* Recursively walks the config object; for any key in the whitelist of secret path keys,
* reads that file and assigns its contents to the corresponding key
* without the suffix, then removes the "_path" property.
*/
const SECRET_PATH_KEYS = [
'pre_authkey_path',
'client_secret_path',
'headscale_api_key_path',
'cookie_secret_path',
] as const;
// For fast set hashing lookups, but we still need the array for typings
const SECRET_PATH_KEY_SET = new Set<string>(SECRET_PATH_KEYS);
type SecretPathKey = (typeof SECRET_PATH_KEYS)[number];
type StripPath<S extends string> = S extends `${infer T}_path` ? T : never;
type KeysToPromote<T> = Extract<keyof T & string, SecretPathKey>;
type MappedKeys<T> = StripPath<KeysToPromote<T>>;
type NonNullablized<T> = Omit<T, KeysToPromote<T> | MappedKeys<T>> & {
[K in MappedKeys<T>]-?: string;
};
type NestedNonNullablized<T> = T extends readonly (infer U)[]
? readonly NestedNonNullablized<U>[]
: T extends (infer U)[]
? NestedNonNullablized<U>[]
: T extends object
? {
[K in keyof NonNullablized<T>]: NestedNonNullablized<
NonNullablized<T>[K]
>;
}
: T;
async function loadSecretsFromFiles<T extends object>(
obj: T,
): Promise<NestedNonNullablized<T>> {
// Work with a Record so we can mutate/delete properties
const record = obj as Record<string, unknown>;
for (const key of Object.keys(record)) {
const val = record[key];
if (val && typeof val === 'object') {
// recurse into nested objects
record[key] = await loadSecretsFromFiles(val);
continue;
}
if (SECRET_PATH_KEY_SET.has(key) && typeof val === 'string') {
try {
const path = interpolateEnvVars(val);
const content = await readFile(path, 'utf8');
const secretKey = key.slice(0, -5); // drop '_path'
record[secretKey] = content.trim();
delete record[key];
log.debug('config', 'Loaded secret from %s → %s', val, secretKey);
} catch (err) {
if (err instanceof ConfigError) throw err;
log.error('config', 'Failed to read secret file %s: %s', val, err);
throw new ConfigError(`Failed to read secret file ${val}: ${err}`);
}
}
}
// Cast back to the original T so callers keep their precise type
return record as NestedNonNullablized<T>;
}
async function validateConfigPath(path: string) {
try {
await access(path, constants.F_OK | constants.R_OK);
log.info('config', 'Found a valid configuration file at %s', path);
return true;
} catch (error) {
log.error('config', 'Unable to read a configuration file at %s', path);
log.error('config', '%s', error);
throw new ConfigError(
`Unable to read configuration file at ${path}: ${error}`,
);
}
}
async function loadConfigFile(path: string): Promise<unknown> {
log.debug('config', 'Reading 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 configuration file at %s', path);
for (const error of configYaml.errors) {
log.error('config', ` - ${error.toString()}`);
}
throw new ConfigError(`Cannot parse configuration file at ${path}`);
}
if (configYaml.warnings.length > 0) {
log.warn(
'config',
'Warnings while parsing configuration file at %s',
path,
);
for (const warning of configYaml.warnings) {
log.warn('config', ` - ${warning.toString()}`);
}
}
return configYaml.toJSON() as unknown;
} catch (e) {
log.error('config', 'Error reading configuration file at %s', path);
log.error('config', '%s', e);
throw new ConfigError(`Error reading configuration file at ${path}: ${e}`);
}
}
export function validateConfig(config: unknown) {
log.debug('config', 'Validating Headplane configuration');
const result = headplaneConfig(config);
if (result instanceof type.errors) {
const errorMessages = [];
for (const [number, error] of result.entries()) {
const errorMsg = error.toString();
log.error('config', ` - (${number}): ${errorMsg}`);
errorMessages.push(errorMsg);
}
throw new ConfigError(errorMessages.join('\n'));
}
return result;
}
function coalesceEnv(config: HeadplaneConfig) {
const envConfig: Record<string, unknown> = {};
const rootKeys: string[] = Object.values(envVariables);
// Typescript is still insanely stupid at nullish filtering
const vars = Object.entries(env).filter(([key, value]) => {
if (!value) {
return false;
}
if (!key.startsWith('HEADPLANE_')) {
return false;
}
// Filter out the rootEnv configurations
if (rootKeys.includes(key)) {
return false;
}
return true;
}) as [string, string][];
log.debug('config', 'Coalescing %s environment variables', vars.length);
for (const [key, value] of vars) {
const configPath = key.replace('HEADPLANE_', '').toLowerCase().split('__');
log.debug(
'config',
` - ${key}=${new Array(value.length).fill('*').join('')}`,
);
let current = envConfig;
while (configPath.length > 1) {
const path = configPath.shift() as string;
if (!(path in current)) {
current[path] = {};
}
current = current[path] as Record<string, unknown>;
}
current[configPath[0]] = value;
}
const toMerge = coalesceConfig(envConfig);
if (!toMerge) {
return;
}
// Deep merge the environment variables into the configuration
// This will overwrite any existing values in the configuration
return deepMerge(config, toMerge);
}
export function coalesceConfig(config: unknown) {
log.debug('config', 'Revalidating config after coalescing variables');
const out = partialHeadplaneConfig(config);
if (out instanceof type.errors) {
log.error('config', 'Error parsing variables:');
for (const [number, error] of out.entries()) {
log.error('config', ` - (${number}): ${error.toString()}`);
}
return;
}
return out;
}
type DeepPartial<T> =
| {
[P in keyof T]?: DeepPartial<T[P]>;
}
| undefined;
function deepMerge<T>(target: T, source: DeepPartial<T>): T {
if (typeof target !== 'object' || typeof source !== 'object')
return source as T;
const result = { ...target } as T;
for (const key in source) {
const val = source[key];
if (val === undefined || val === null) {
continue;
}
if (typeof val === 'object') {
result[key] = deepMerge(result[key], val);
continue;
}
result[key] = val;
}
return result;
}
-223
View File
@@ -1,223 +0,0 @@
import { type } from 'arktype';
const stringToBool = type('string | boolean').pipe((v) => {
if (typeof v === 'string') {
if (v === '1' || v === 'true' || v === 'yes') {
return true;
}
if (v === '0' || v === 'false' || v === 'no') {
return false;
}
throw new Error(`Invalid string value for boolean: ${v}`);
}
return Boolean(v);
});
const serverConfig = type({
host: 'string.ip',
port: type('string | number.integer').pipe((v) => Number(v)),
data_path: 'string = "/var/lib/headplane/"',
cookie_secret: '(32 <= string <= 32)?',
cookie_secret_path: 'string?',
cookie_secure: stringToBool,
})
.narrow((obj: Record<string, unknown>, ctx: any) => {
const hasVal = obj.cookie_secret != null && `${obj.cookie_secret}` !== '';
const hasPath =
obj.cookie_secret_path != null && obj.cookie_secret_path !== '';
if (hasVal && hasPath)
return ctx.reject(
`Only one of "cookie_secret" or "cookie_secret_path" may be set.`,
);
if (!hasVal && !hasPath)
return ctx.reject(
`Either "cookie_secret" or "cookie_secret_path" must be provided for cookie_secret.`,
);
return true;
})
.onDeepUndeclaredKey('reject');
const partialServerConfig = type({
host: 'string.ip?',
port: type('string | number.integer')
.pipe((v) => Number(v))
.optional(),
data_path: 'string = "/var/lib/headplane/"',
cookie_secret: '32 <= string <= 32?',
cookie_secret_path: 'string?',
cookie_secure: stringToBool.optional(),
});
const oidcConfig = type({
issuer: 'string.url',
client_id: 'string',
client_secret: 'string?',
client_secret_path: 'string?',
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"',
redirect_uri: 'string.url?',
user_storage_file: 'string = "/var/lib/headplane/users.json"',
disable_api_key_login: stringToBool,
headscale_api_key: 'string?',
headscale_api_key_path: 'string?',
profile_picture_source: '"oidc" | "gravatar" = "oidc"',
strict_validation: stringToBool.default(true),
scope: 'string = "openid email profile"',
extra_params: 'Record<string, string>?',
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
})
.narrow((obj: Record<string, unknown>, ctx: any) => {
const hasVal =
obj.headscale_api_key != null && `${obj.headscale_api_key}` !== '';
const hasPath =
obj.headscale_api_key_path != null && obj.headscale_api_key_path !== '';
if (hasVal && hasPath)
return ctx.reject(
`Only one of "headscale_api_key" or "headscale_api_key_path" may be set.`,
);
if (!hasVal && !hasPath)
return ctx.reject(
`Either "headscale_api_key" or "headscale_api_key_path" must be provided.`,
);
return true;
})
.onDeepUndeclaredKey('reject');
const partialOidcConfig = type({
issuer: 'string.url?',
client_id: 'string?',
client_secret: 'string?',
client_secret_path: 'string?',
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
redirect_uri: 'string.url?',
user_storage_file: 'string?',
disable_api_key_login: stringToBool.optional(),
headscale_api_key: 'string?',
headscale_api_key_path: 'string?',
profile_picture_source: '("oidc" | "gravatar")?',
strict_validation: stringToBool.default(true),
scope: 'string?',
extra_params: 'Record<string, string>?',
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
});
const headscaleConfig = type({
url: type('string.url').pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v)),
tls_cert_path: 'string?',
public_url: 'string.url?',
config_path: 'string?',
config_strict: stringToBool,
dns_records_path: 'string?',
}).onDeepUndeclaredKey('reject');
const partialHeadscaleConfig = type({
url: type('string.url')
.pipe((v) => (v.endsWith('/') ? v.slice(0, -1) : v))
.optional(),
tls_cert_path: 'string?',
public_url: 'string.url?',
config_path: 'string?',
config_strict: stringToBool.optional(),
dns_records_path: 'string?',
});
const agentConfig = type({
enabled: stringToBool.default(false),
host_name: 'string = "headplane-agent"',
pre_authkey: 'string?',
pre_authkey_path: 'string?',
cache_ttl: 'number.integer = 180000',
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
executable_path: 'string = "/usr/libexec/headplane/agent"',
work_dir: 'string = "/var/lib/headplane/agent"',
})
.narrow((obj: Record<string, unknown>, ctx: any) => {
const hasVal = obj.pre_authkey != null && `${obj.pre_authkey}` !== '';
const hasPath = obj.pre_authkey_path != null && obj.pre_authkey_path !== '';
if (hasVal && hasPath)
return ctx.reject(
`Only one of "pre_authkey" or "pre_authkey_path" may be set.`,
);
if (!hasVal && !hasPath)
return ctx.reject(
`Either "pre_authkey" or "pre_authkey_path" must be provided.`,
);
return true;
})
.onDeepUndeclaredKey('reject');
const partialAgentConfig = type({
enabled: stringToBool.default(false),
host_name: 'string = "headplane-agent"',
pre_authkey: 'string?',
pre_authkey_path: 'string?',
cache_ttl: 'number.integer = 180000',
cache_path: 'string = "/var/lib/headplane/agent_cache.json"',
executable_path: 'string = "/usr/libexec/headplane/agent"',
work_dir: 'string = "/var/lib/headplane/agent"',
});
const dockerConfig = type({
enabled: stringToBool,
container_name: 'string = ""',
container_label: 'string = "me.tale.headplane.target=headscale"',
socket: 'string = "unix:///var/run/docker.sock"',
});
const partialDockerConfig = type({
enabled: stringToBool,
container_name: 'string | undefined',
container_label: 'string | undefined',
socket: 'string | undefined',
}).partial();
const kubernetesConfig = type({
enabled: stringToBool,
pod_name: 'string',
validate_manifest: stringToBool,
});
const procConfig = type({
enabled: stringToBool,
});
const integrationConfig = type({
'docker?': dockerConfig,
'kubernetes?': kubernetesConfig,
'proc?': procConfig,
'agent?': agentConfig,
});
const partialIntegrationConfig = type({
'docker?': partialDockerConfig,
'kubernetes?': kubernetesConfig.partial(),
'proc?': procConfig.partial(),
'agent?': partialAgentConfig,
}).partial();
export const headplaneConfig = type({
debug: stringToBool,
server: serverConfig,
'oidc?': oidcConfig,
'integration?': integrationConfig,
headscale: headscaleConfig,
}).onDeepUndeclaredKey('delete');
export const partialHeadplaneConfig = type({
debug: stringToBool,
server: partialServerConfig,
'oidc?': partialOidcConfig,
'integration?': partialIntegrationConfig,
headscale: partialHeadscaleConfig,
}).partial();
export type HeadplaneConfig = typeof headplaneConfig.infer;
export type PartialHeadplaneConfig = typeof partialHeadplaneConfig.infer;
+9
View File
@@ -0,0 +1,9 @@
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;
};
}
+5 -10
View File
@@ -1,14 +1,12 @@
import { eq, isNotNull } from 'drizzle-orm';
import { LoaderFunctionArgs } from 'react-router';
import { Machine } from '~/types';
import log from '~/utils/log';
import { LoadContext } from '..';
import type { Route } from '../../layouts/+types/dashboard';
import { ephemeralNodes } from './schema';
export async function pruneEphemeralNodes({
context,
request,
}: LoaderFunctionArgs<LoadContext>) {
}: Route.LoaderArgs) {
const session = await context.sessions.auth(request);
const ephemerals = await context.db
.select()
@@ -20,11 +18,8 @@ export async function pruneEphemeralNodes({
return;
}
const { nodes } = await context.client.get<{ nodes: Machine[] }>(
'v1/node',
session.api_key,
);
const api = context.hsApi.getRuntimeClient(session.api_key);
const nodes = await api.getNodes();
const toPrune = nodes.filter((node) => {
if (node.online) {
return false;
@@ -42,7 +37,7 @@ export async function pruneEphemeralNodes({
const promises = toPrune.map((node) => {
return async () => {
log.debug('api', `Pruning node ${node.name}`);
await context.client.delete(`v1/node/${node.id}`, session.api_key);
await api.deleteNode(node.id);
await context.db
.delete(ephemeralNodes)
-263
View File
@@ -1,263 +0,0 @@
import { readFile } from 'node:fs/promises';
import { data } from 'react-router';
import { Agent, Dispatcher, request } from 'undici';
import { errors } from 'undici';
import log from '~/utils/log';
import ResponseError from './api-error';
function isNodeNetworkError(error: unknown): error is NodeJS.ErrnoException {
const keys = Object.keys(error as Record<string, unknown>);
return (
typeof error === 'object' &&
error !== null &&
keys.includes('code') &&
keys.includes('errno')
);
}
function friendlyError(givenError: unknown) {
let error: unknown = givenError;
if (error instanceof AggregateError) {
error = error.errors[0];
}
switch (true) {
case error instanceof errors.BodyTimeoutError:
case error instanceof errors.ConnectTimeoutError:
case error instanceof errors.HeadersTimeoutError:
return data('Timed out waiting for a response from the Headscale API', {
statusText: 'Request Timeout',
status: 408,
});
case error instanceof errors.SocketError:
case error instanceof errors.SecureProxyConnectionError:
case error instanceof errors.ClientClosedError:
case error instanceof errors.ClientDestroyedError:
case error instanceof errors.RequestAbortedError:
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
case error instanceof errors.InvalidArgumentError:
case error instanceof errors.InvalidReturnValueError:
case error instanceof errors.NotSupportedError:
return data('Unable to make a request (this is most likely a bug)', {
statusText: 'Internal Server Error',
status: 500,
});
case error instanceof errors.HeadersOverflowError:
case error instanceof errors.RequestContentLengthMismatchError:
case error instanceof errors.ResponseContentLengthMismatchError:
case error instanceof errors.ResponseExceededMaxSizeError:
return data('The Headscale API returned a malformed response', {
statusText: 'Bad Gateway',
status: 502,
});
case isNodeNetworkError(error):
if (error.code === 'ECONNREFUSED') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENOTFOUND') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'EAI_AGAIN') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ETIMEDOUT') {
return data('Timed out waiting for a response from the Headscale API', {
statusText: 'Request Timeout',
status: 408,
});
}
if (error.code === 'ECONNRESET') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'EPIPE') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENETUNREACH') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
if (error.code === 'ENETRESET') {
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
}
return data('The Headscale API is not reachable', {
statusText: 'Service Unavailable',
status: 503,
});
default:
return data((error as Error).message ?? 'An unknown error occurred', {
statusText: 'Internal Server Error',
status: 500,
});
}
}
export async function createApiClient(base: string, certPath?: string) {
if (!certPath) {
return new ApiClient(new Agent(), base);
}
try {
log.debug('config', 'Loading certificate from %s', certPath);
const data = await readFile(certPath, 'utf8');
log.info('config', 'Using certificate from %s', certPath);
return new ApiClient(new Agent({ connect: { ca: data.trim() } }), base);
} catch (error) {
log.error('config', 'Failed to load Headscale TLS cert: %s', error);
log.debug('config', 'Error Details: %o', error);
return new ApiClient(new Agent(), base);
}
}
export class ApiClient {
private agent: Agent;
private base: string;
constructor(agent: Agent, base: string) {
this.agent = agent;
this.base = base;
}
private async defaultFetch(
url: string,
options?: Partial<Dispatcher.RequestOptions>,
) {
const method = options?.method ?? 'GET';
log.debug('api', '%s %s', method, url);
try {
const res = await request(new URL(url, this.base), {
dispatcher: this.agent,
headers: {
...options?.headers,
Accept: 'application/json',
'User-Agent': `Headplane/${__VERSION__}`,
},
body: options?.body,
method,
});
return res;
} catch (error: unknown) {
throw friendlyError(error);
}
}
async healthcheck() {
try {
const res = await request(new URL('/health', this.base), {
dispatcher: this.agent,
headers: {
Accept: 'application/json',
'User-Agent': `Headplane/${__VERSION__}`,
},
});
return res.statusCode === 200;
} catch (error) {
log.debug('api', 'Healthcheck failed %o', error);
return false;
}
}
async get<T = unknown>(url: string, key: string) {
const res = await this.defaultFetch(`/api/${url}`, {
headers: {
Authorization: `Bearer ${key}`,
},
});
if (res.statusCode >= 400) {
log.debug('api', 'GET %s failed with status %d', url, res.statusCode);
throw new ResponseError(res.statusCode, await res.body.text());
}
return res.body.json() as Promise<T>;
}
async post<T = unknown>(url: string, key: string, body?: unknown) {
const res = await this.defaultFetch(`/api/${url}`, {
method: 'POST',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
});
if (res.statusCode >= 400) {
log.debug('api', 'POST %s failed with status %d', url, res.statusCode);
throw new ResponseError(res.statusCode, await res.body.text());
}
return res.body.json() as Promise<T>;
}
async put<T = unknown>(url: string, key: string, body?: unknown) {
const res = await this.defaultFetch(`/api/${url}`, {
method: 'PUT',
body: body ? JSON.stringify(body) : undefined,
headers: {
Authorization: `Bearer ${key}`,
},
});
if (res.statusCode >= 400) {
log.debug('api', 'PUT %s failed with status %d', url, res.statusCode);
throw new ResponseError(res.statusCode, await res.body.text());
}
return res.body.json() as Promise<T>;
}
async delete<T = unknown>(url: string, key: string) {
const res = await this.defaultFetch(`/api/${url}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${key}`,
},
});
if (res.statusCode >= 400) {
log.debug('api', 'DELETE %s failed with status %d', url, res.statusCode);
throw new ResponseError(res.statusCode, await res.body.text());
}
return res.body.json() as Promise<T>;
}
}
@@ -0,0 +1,23 @@
import type { Key } from '~/types';
import { defineApiEndpoints } from '../factory';
export interface ApiKeyEndpoints {
/**
* Retrieves all API keys from the Headscale instance.
*
* @returns An array of `Key` objects representing the API keys.
*/
getApiKeys(): Promise<Key[]>;
}
export default defineApiEndpoints<ApiKeyEndpoints>((client, apiKey) => ({
getApiKeys: async () => {
const { apiKeys } = await client.apiFetch<{ apiKeys: Key[] }>(
'GET',
'v1/apikey',
apiKey,
);
return apiKeys;
},
}));
@@ -0,0 +1,79 @@
import {
composeEndpoints,
defineApiEndpoints,
type ExtractApiEndpoints,
type UnionToIntersection,
} from '../factory';
import type { HeadscaleApiInterface } from '../index';
import apiKeyEndpoints from './api-keys';
import nodeEndpoints from './nodes';
import policyEndpoints from './policy';
import preAuthKeyEndpoints from './pre-auth-keys';
import userEndpoints from './users';
interface HealthcheckEndpoint {
/**
* Checks if the Headscale instance is healthy.
*
* @returns A boolean indicating if the instance is healthy.
*/
isHealthy(): Promise<boolean>;
}
const healthcheckEndpoint = defineApiEndpoints<HealthcheckEndpoint>(
(client, apiKey) => ({
isHealthy: async () => {
try {
const res = await client.rawFetch('/health', {
method: 'GET',
headers: {
// This doesn't really matter
Authorization: `Bearer ${apiKey}`,
},
});
return res.statusCode === 200;
} catch {
return false;
}
},
}),
);
/**
* A constant list of all endpoint groups.
* Add new endpoint groups here.
*/
export const endpointSets = [
apiKeyEndpoints,
healthcheckEndpoint,
nodeEndpoints,
policyEndpoints,
preAuthKeyEndpoints,
userEndpoints,
] as const;
/**
* All of the available API methods when interacting with Headscale's API.
* We have wrapped each operation with nice methods and parameters to make it
* easier to do integration testing by spinning up an actual Headscale instance
* and calling these methods against it.
*
* We also have the benefit of supporting multiple Headscale versions by
* passing in different internal implementations based on the OpenAPI spec.
*/
export type RuntimeApiClient = UnionToIntersection<
ExtractApiEndpoints<(typeof endpointSets)[number]>
>;
/**
* Composes all endpoint groups into a single runtime API client.
*
* @param client - The client helpers for making API requests.
* @param apiKey - The API key for authentication.
* @returns A fully composed runtime API client.
*/
export default (
client: HeadscaleApiInterface['clientHelpers'],
apiKey: string,
) => composeEndpoints(endpointSets, client, apiKey);
+150
View File
@@ -0,0 +1,150 @@
import type { Machine } from '~/types';
import { defineApiEndpoints } from '../factory';
export interface NodeEndpoints {
/**
* Retrieves all nodes (machines) from the Headscale instance.
*
* @returns An array of `Machine` objects representing the nodes.
*/
getNodes(): Promise<Machine[]>;
/**
* Retrieves a specific node (machine) by its ID.
*
* @param id The ID of the node to retrieve.
* @returns A `Machine` object representing the node.
*/
getNode(id: string): Promise<Machine>;
/**
* Deletes a specific node (machine) by its ID.
*
* @param id The ID of the node to delete.
*/
deleteNode(id: string): Promise<void>;
/**
* Registers a new node (machine) with the given user and key.
*
* @param user The user to associate with the node.
* @param key The registration key for the node.
* @returns A `Machine` object representing the newly registered node.
*/
registerNode(user: string, key: string): Promise<Machine>;
/**
* Approves routes for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param routes An array of routes to approve for the node.
*/
approveNodeRoutes(id: string, routes: string[]): Promise<void>;
/**
* Expires a specific node (machine) by its ID.
*
* @param id The ID of the node to expire.
*/
expireNode(id: string): Promise<void>;
/**
* Renames a specific node (machine) by its ID.
*
* @param id The ID of the node to rename.
* @param newName The new name for the node.
*/
renameNode(id: string, newName: string): Promise<void>;
/**
* Sets tags for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param tags An array of tags to set for the node.
*/
setNodeTags(id: string, tags: string[]): Promise<void>;
/**
* Sets the user for a specific node (machine) by its ID.
*
* @param id The ID of the node.
* @param user The user to set for the node.
*/
setNodeUser(id: string, user: string): Promise<void>;
}
export default defineApiEndpoints<NodeEndpoints>((client, apiKey) => ({
getNodes: async () => {
const { nodes } = await client.apiFetch<{ nodes: Machine[] }>(
'GET',
'v1/node',
apiKey,
);
return nodes;
},
getNode: async (nodeId) => {
const { node } = await client.apiFetch<{ node: Machine }>(
'GET',
`v1/node/${nodeId}`,
apiKey,
);
return node;
},
deleteNode: async (nodeId) => {
await client.apiFetch<void>('DELETE', `v1/node/${nodeId}`, apiKey);
},
registerNode: async (user, key) => {
const qp = new URLSearchParams();
qp.append('user', user);
qp.append('key', key);
const { node } = await client.apiFetch<{ node: Machine }>(
'POST',
`v1/node/register?${qp.toString()}`,
apiKey,
{
user,
key,
},
);
return node;
},
approveNodeRoutes: async (nodeId, routes) => {
await client.apiFetch<void>(
'POST',
`v1/node/${nodeId}/approve_routes`,
apiKey,
{ routes },
);
},
expireNode: async (nodeId) => {
await client.apiFetch<void>('POST', `v1/node/${nodeId}/expire`, apiKey);
},
renameNode: async (nodeId, newName) => {
await client.apiFetch<void>(
'POST',
`v1/node/${nodeId}/rename/${newName}`,
apiKey,
);
},
setNodeTags: async (nodeId, tags) => {
await client.apiFetch<void>('POST', `v1/node/${nodeId}/tags`, apiKey, {
tags,
});
},
setNodeUser: async (nodeId, user) => {
await client.apiFetch<void>('POST', `v1/node/${nodeId}/user`, apiKey, {
user,
});
},
}));
@@ -0,0 +1,41 @@
import { defineApiEndpoints } from '../factory';
export interface PolicyEndpoints {
/**
* Retrieves the current ACL policy from the Headscale instance.
*
* @returns The ACL policy as a string and the date it was last updated.
*/
getPolicy(): Promise<{ policy: string; updatedAt: Date | null }>;
/**
* Sets the ACL policy for the Headscale instance.
*
* @param policy The ACL policy as a string.
* @returns The updated ACL policy as a string and the date it was last updated.
*/
setPolicy(policy: string): Promise<{ policy: string; updatedAt: Date }>;
}
export default defineApiEndpoints<PolicyEndpoints>((client, apiKey) => ({
getPolicy: async () => {
const { policy, updatedAt } = await client.apiFetch<{
policy: string;
updatedAt: string;
}>('GET', 'v1/policy', apiKey);
return {
policy,
updatedAt: updatedAt !== null ? new Date(updatedAt) : null,
};
},
setPolicy: async (policy) => {
const { policy: newPolicy, updatedAt } = await client.apiFetch<{
policy: string;
updatedAt: string;
}>('PUT', 'v1/policy', apiKey, { policy });
return { policy: newPolicy, updatedAt: new Date(updatedAt) };
},
}));
@@ -0,0 +1,69 @@
import type { PreAuthKey } from '~/types';
import { defineApiEndpoints } from '../factory';
export interface PreAuthKeyEndpoints {
/**
* Retrieves all pre-authentication keys for a specific user.
*
* @param user The user to retrieve pre-authentication keys for.
* @returns An array of `PreAuthKey` objects representing the pre-authentication keys.
*/
getPreAuthKeys(user: string): Promise<PreAuthKey[]>;
/**
* Creates a new pre-authentication key for a specific user.
*
* @param user The user to create the pre-authentication key for.
* @param ephemeral Whether the key is ephemeral.
* @param reusable Whether the key is reusable.
* @param expiration The expiration date of the key, or `null` for no expiration.
* @param aclTags An array of ACL tags to associate with the key, or `null` for none.
* @returns A `PreAuthKey` object representing the newly created pre-authentication key.
*/
createPreAuthKey(
user: string,
ephemeral: boolean,
reusable: boolean,
expiration: Date | null,
aclTags: string[] | null,
): Promise<PreAuthKey>;
/**
* Expires a specific pre-authentication key for a user.
*
* @param user The user associated with the pre-authentication key.
* @param key The pre-authentication key to expire.
*/
expirePreAuthKey(user: string, key: string): Promise<void>;
}
export default defineApiEndpoints<PreAuthKeyEndpoints>((client, apiKey) => ({
getPreAuthKeys: async (user) => {
const { preAuthKeys } = await client.apiFetch<{
preAuthKeys: PreAuthKey[];
}>('GET', 'v1/preauthkey', apiKey, { user });
return preAuthKeys;
},
createPreAuthKey: async (user, ephemeral, reusable, expiration, aclTags) => {
const { preAuthKey } = await client.apiFetch<{
preAuthKey: PreAuthKey;
}>('POST', 'v1/preauthkey', apiKey, {
user,
ephemeral,
reusable,
expiration: expiration ? expiration.toISOString() : null,
aclTags,
});
return preAuthKey;
},
expirePreAuthKey: async (user, key) => {
await client.apiFetch<void>('POST', 'v1/preauthkey/expire', apiKey, {
user,
key,
});
},
}));
@@ -0,0 +1,88 @@
import type { User } from '~/types';
import { defineApiEndpoints } from '../factory';
export interface UserEndpoints {
/**
* Retrieves users from the Headscale instance, optionally filtering by ID, name, or email.
*
* @param id Optional ID of the user to retrieve.
* @param name Optional name of the user to retrieve.
* @param email Optional email of the user to retrieve.
* @returns An array of `User` objects representing the users.
*/
getUsers(id?: string, name?: string, email?: string): Promise<User[]>;
/**
* Creates a new user in the Headscale instance.
*
* @param username The username of the new user.
* @param email Optional email of the new user.
* @param displayName Optional display name of the new user.
* @param pictureUrl Optional picture URL of the new user.
* @returns A `User` object representing the newly created user.
*/
createUser(
username: string,
email?: string,
displayName?: string,
pictureUrl?: string,
): Promise<User>;
/**
* Deletes a specific user by its ID.
*
* @param id The ID of the user to delete.
*/
deleteUser(id: string): Promise<void>;
/**
* Renames a specific user by its ID.
*
* @param id The ID of the user to rename.
* @param newName The new name for the user.
*/
renameUser(id: string, newName: string): Promise<void>;
}
export default defineApiEndpoints<UserEndpoints>((client, apiKey) => ({
getUsers: async (id, name, email) => {
const moreThanOneFilter =
[id, name, email].filter((v) => v !== undefined).length > 1;
if (moreThanOneFilter) {
throw new Error('Only one of id, name, or email filters can be provided');
}
const { users } = await client.apiFetch<{ users: User[] }>(
'GET',
'v1/user',
apiKey,
{ id, name, email },
);
return users;
},
createUser: async (username, email, displayName, pictureUrl) => {
const { user } = await client.apiFetch<{ user: User }>(
'POST',
'v1/user',
apiKey,
{ name: username, email, displayName, pictureUrl },
);
return user;
},
deleteUser: async (id) => {
await client.apiFetch<void>('DELETE', `v1/user/${id}`, apiKey);
},
renameUser: async (oldId, newName) => {
await client.apiFetch<void>(
'POST',
`v1/user/${oldId}/rename/${newName}`,
apiKey,
);
},
}));
+57
View File
@@ -0,0 +1,57 @@
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;
}
/**
* Type guard to check if an error is a HeadscaleAPIError.
* @param error - The error to check.
* @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
);
}
/**
* Type guard to check if an error is a HeadscaleConnectionError.
* @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 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
);
}
+72
View File
@@ -0,0 +1,72 @@
import { errors } from 'undici';
/**
* Helper function that determines if an error is a Node.js exception
* @param - The error to check
* @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
);
}
/**
* A friendly error representation for Headscale connection issues.
*/
export interface HeadscaleConnectionError {
requestUrl: string;
errorCode: string;
errorMessage: string;
extraData: Record<string, unknown> | null;
}
/**
* Convert an Undici error into a friendly HeadscaleAPIError.
* This is used to avoid exposing rough error edges to the user.
*
* @param error - The Undici error to convert.
* @param requestUrl - The URL of the request that caused the error.
* @returns A friendly HeadscaleAPIError.
*/
export function undiciToFriendlyError(
error: unknown,
requestUrl: string,
): HeadscaleConnectionError {
// 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 (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,
};
}
+45
View File
@@ -0,0 +1,45 @@
import type { HeadscaleApiInterface } from '../api';
/**
* Creates a strongly-typed group factory for a given endpoint interface.
*
* Example:
* export const apiKeyGroup = defineGroup<ApiKeyEndpoints>({...})
*/
export interface EndpointFactory<T extends object> {
__type?: T;
(client: HeadscaleApiInterface['clientHelpers'], apiKey: string): T;
}
export function defineApiEndpoints<T extends object>(
factories: (
client: HeadscaleApiInterface['clientHelpers'],
apiKey: string,
) => T,
): EndpointFactory<T> {
return factories;
}
export type ExtractApiEndpoints<F> = F extends EndpointFactory<infer T>
? T
: never;
export type UnionToIntersection<U> = (
U extends any
? (k: U) => void
: never
) extends (k: infer I) => void
? I
: never;
/**
* Compose multiple endpoint sets into a single typed runtime client
*/
export function composeEndpoints<T extends readonly EndpointFactory<any>[]>(
factories: T,
clientHelpers: any,
apiKey: string,
): UnionToIntersection<ExtractApiEndpoints<T[number]>> {
const instances = factories.map((f) => f(clientHelpers, apiKey));
return Object.assign({}, ...instances) as any;
}
+52
View File
@@ -0,0 +1,52 @@
import { createHash } from 'node:crypto';
import { dereference } from '@readme/openapi-parser';
import { OpenAPIV2 } from 'openapi-types';
/**
* A map of operation IDs to their hashes.
*/
export interface DocumentHash {
[operationId: string]: string;
}
/**
* Given an OpenAPI v2 document, generate a map of operations with hashes.
* This gives us deterministic identifers to determine the version of Headscale
* that is being used at runtime.
*
* @param doc The OpenAPI v2 document to hash.
* @returns A map of operation IDs to their hashes.
*/
export async function hashOpenApiDocument(
doc: OpenAPIV2.Document,
): Promise<DocumentHash> {
const spec = await dereference(doc);
const hashes: DocumentHash = {};
const seen = new Set<string>();
for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(item)) {
if (typeof operation !== 'object') {
continue;
}
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
const raw = JSON.stringify(
{
path,
method: method.toUpperCase(),
parameters,
responses,
},
Object.keys({ path, method, parameters, responses }).sort(),
);
const hash = createHash('md5').update(raw).digest('hex').slice(0, 16);
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
seen.add(final);
hashes[`${method.toUpperCase()} ${path}`] = final;
}
}
return hashes;
}
+340
View File
@@ -0,0 +1,340 @@
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';
import { dereference } from '@readme/openapi-parser';
import type { OpenAPIV2 } from 'openapi-types';
import { data } from 'react-router';
import { Agent, type Dispatcher, request } from 'undici';
import log from '~/utils/log';
import endpointSets, { RuntimeApiClient } from './endpoints';
import { undiciToFriendlyError } from './error';
import { HeadscaleAPIError, isApiError } from './error-client';
import { detectApiVersion, isAtLeast, type Version } from './version';
/**
* A low-level composed interface for interacting with the Headscale API.
* This interface provides direct access to the underlying Undici agent
* and methods for making API requests.
*
* It is also responsible for handling OpenAPI spec polling and hashing to
* determine the implementations of API methods when requested for use.
*/
export interface HeadscaleApiInterface {
/**
* The underlying Undici agent used for making requests.
*/
undiciAgent: Agent;
/**
* The base URL of the Headscale API.
*/
baseUrl: string;
/**
* The OpenAPI hashes retrieved from the Headscale instance at runtime.
* This is used to determine which implementations of API methods to use.
*/
openapiHashes: Record<string, string> | null;
/**
* The detected API version of the connected Headscale instance.
*/
apiVersion: Version;
/**
* Retrieves a runtime API client for the given API key.
*
* @param apiKey The API key to use for authentication.
* @returns A `RuntimeApiClient` instance for interacting with the API.
*/
getRuntimeClient(apiKey: string): RuntimeApiClient;
/**
* A set of helper methods made available to API method implementations.
* The idea is to make interacting with the API easier by providing
* common functionality that can be reused across multiple methods.
*/
clientHelpers: {
/**
* Checks if the connected Headscale instance's API version
* is at least the specified version.
*
* @param version The version to check against.
* @returns `true` if the API version is at least the specified version, `false` otherwise.
*/
isAtleast(version: Version): boolean;
/**
* Makes a raw fetch request to the Headscale API via the Undici agent.
* This method is used internally by API method implementations
* to make requests to the Headscale API.
*
* @param path The API path to request.
* @param options Optional request options.
* @returns A promise that resolves to the response data.
*/
rawFetch(
path: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData>;
/**
* Makes a typed API fetch request to the Headscale API.
* This method is used internally by API method implementations
* to make requests to the Headscale API and parse the response.
*
* @param method The HTTP method to use.
* @param apiPath The API path to request.
* @param apiKey The API key to use for authentication.
* @param bodyOrQuery Optional body or query parameters.
* @returns A promise that resolves to the typed response data.
*/
apiFetch<T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T>;
};
}
/**
* Creates a new Headscale API client interface.
*
* @param baseUrl The base URL of the Headscale API.
* @param certPath Optional path to a custom TLS certificate for secure connections.
* @returns A promise that resolves to a `HeadscaleApiClient` instance.
*/
export async function createHeadscaleInterface(
baseUrl: string,
certPath?: string,
): Promise<HeadscaleApiInterface> {
const undiciAgent = await createUndiciAgent(certPath);
let openapiHashes: Record<string, string> | null = null;
let apiVersion: Version;
const rawFetch = async (
url: string,
options?: Partial<Dispatcher.RequestOptions>,
): Promise<Dispatcher.ResponseData> => {
const method = options?.method ?? 'GET';
log.debug('api', '%s %s', method, url);
try {
const res = await request(new URL(url, baseUrl), {
dispatcher: undiciAgent,
headers: {
...options?.headers,
Accept: 'application/json',
'User-Agent': `Headplane/${__VERSION__}`,
},
body: options?.body,
method,
});
return res;
} catch (error) {
const errorBody = undiciToFriendlyError(error, `${method} ${url}`);
throw data(errorBody, {
status: 502,
statusText: 'Bad Gateway',
});
}
};
const apiFetch = async <T>(
method: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH',
apiPath: `v1/${string}`,
apiKey: string,
bodyOrQuery?: Record<string, unknown>,
): Promise<T> => {
let url = `/api/${apiPath}`;
const options: Partial<Dispatcher.RequestOptions> = {
method: method,
headers: {
Authorization: `Bearer ${apiKey}`,
},
};
if (bodyOrQuery) {
if (method === 'GET' || method === 'DELETE') {
// Filter out undefined values
const params = new URLSearchParams();
for (const [key, value] of Object.entries(bodyOrQuery)) {
if (value !== undefined) {
params.append(key, String(value));
}
}
if ([...params.keys()].length > 0) {
url += `?${params.toString()}`;
}
} else {
options.body = JSON.stringify(bodyOrQuery);
options.headers = {
...options.headers,
'Content-Type': 'application/json',
};
}
}
const res = await rawFetch(url, options);
if (res.statusCode >= 400) {
log.debug(
'api',
'%s %s failed with status %d',
method,
apiPath,
res.statusCode,
);
const rawData = await res.body.text();
const jsonData = (() => {
try {
return JSON.parse(rawData) as Record<string, unknown>;
} catch {
return null;
}
})();
throw data(
{
requestUrl: `${method} ${apiPath}`,
statusCode: res.statusCode,
rawData,
data: jsonData,
} satisfies HeadscaleAPIError,
{
status: 502,
statusText: 'Bad Gateway',
},
);
}
return res.body.json() as Promise<T>;
};
/**
* Polls the OpenAPI spec endpoint and generates operation hashes.
* This is used to determine which implementations of API methods to use.
*
* @returns A promise that resolves to the OpenAPI operation hashes.
*/
async function fetchAndHashOpenapi(): Promise<Record<string, string> | null> {
try {
const res = await rawFetch('/swagger/v1/openapiv2.json');
if (res.statusCode !== 200) {
log.error('api', 'Failed to fetch OpenAPI spec: %d', res.statusCode);
return null;
}
const body = await res.body.json();
const spec = await dereference(body as OpenAPIV2.Document);
const hashes = generateSpecHashes(spec);
log.debug(
'api',
'OpenAPI hashes updated (%d endpoints)',
Object.keys(hashes).length,
);
return hashes;
} catch (error) {
if (isApiError(error)) {
log.debug('api', 'Failed to fetch OpenAPI spec: %d', error.statusCode);
}
return null;
}
}
const isAtleast = (version: Version): boolean => {
return isAtLeast(apiVersion, version);
};
openapiHashes = await fetchAndHashOpenapi();
apiVersion = detectApiVersion(openapiHashes);
setInterval(async () => {
const hashes = await fetchAndHashOpenapi();
if (hashes) {
openapiHashes = hashes;
apiVersion = detectApiVersion(openapiHashes);
}
}, 60_000); // every 60 seconds
return {
undiciAgent,
baseUrl,
openapiHashes,
apiVersion,
getRuntimeClient: (apiKey: string) =>
endpointSets(
{
rawFetch,
apiFetch,
isAtleast,
},
apiKey,
),
clientHelpers: {
rawFetch,
apiFetch,
isAtleast,
},
};
}
/**
* Creates a new Undici agent for making HTTP requests.
*
* @param certPath Optional path to a custom TLS certificate for secure connections.
* @returns A promise that resolves to an `Agent` instance.
*/
async function createUndiciAgent(certPath?: string): Promise<Agent> {
if (!certPath) {
return new Agent();
}
try {
log.debug('config', 'Loading certificate from %s', certPath);
const data = await readFile(certPath, 'utf8');
log.info('config', 'Using certificate from %s', certPath);
return new Agent({ connect: { ca: data.trim() } });
} catch (error) {
log.error('config', 'Failed to load Headscale TLS cert: %s', error);
log.debug('config', 'Error Details: %o', error);
return new Agent();
}
}
function generateSpecHashes(spec: OpenAPIV2.Document) {
const hashes: Record<string, string> = {};
const seen = new Set<string>();
for (const [path, item] of Object.entries(spec.paths)) {
for (const [method, operation] of Object.entries(item)) {
if (typeof operation !== 'object') {
continue;
}
const { parameters, responses } = operation as OpenAPIV2.OperationObject;
const raw = JSON.stringify(
{
path,
method: method.toUpperCase(),
parameters,
responses,
},
Object.keys({ path, method, parameters, responses }).sort(),
);
const hash = createHash('md5').update(raw).digest('hex').slice(0, 16);
const final = seen.has(hash) ? `${hash}_${seen.size}` : hash;
seen.add(final);
hashes[`${method.toUpperCase()} ${path}`] = final;
}
}
return hashes;
}
@@ -3,13 +3,15 @@
export default class ResponseError extends Error {
status: number;
response: string;
requestUrl: string;
responseObject?: Record<string, unknown>;
constructor(status: number, response: string) {
super(`Response Error (${status}): ${response}`);
constructor(status: number, response: string, requestUrl: string) {
super(`${requestUrl}: status ${status} - ${response}`);
this.name = 'ResponseError';
this.status = status;
this.response = response;
this.requestUrl = requestUrl;
try {
// Try to parse the response as JSON to get a response object
+103
View File
@@ -0,0 +1,103 @@
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.
*/
export type Version = keyof typeof hashes;
const VERSIONS = Object.keys(hashes) as Version[];
/**
* Detects the closest matching API version using operation hashes.
* Falls back to the latest known version and emits a warning if unknown.
*
* @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;
}
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;
}
}
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,
);
}
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,
);
return bestVersion;
}
if (canonical !== bestVersion) {
log.info(
'api',
'Canonicalizing detected version %s → %s (same schema)',
bestVersion,
canonical,
);
}
return canonical;
}
/**
* Checks if the current version is at least the baseline version.
*
* @param current - The current API version.
* @param baseline - The baseline API version to compare against.
* @returns True if current is at least baseline, false otherwise.
*/
export function isAtLeast(current: Version, baseline: Version) {
return VERSIONS.indexOf(current) >= VERSIONS.indexOf(baseline);
}
+1 -1
View File
@@ -1,4 +1,4 @@
import { constants, access, readFile, writeFile } from 'node:fs/promises';
import { access, constants, readFile, writeFile } from 'node:fs/promises';
import { setTimeout } from 'node:timers/promises';
import log from '~/utils/log';
+1 -1
View File
@@ -7,7 +7,7 @@ import { inArray } from 'drizzle-orm';
import { LibSQLDatabase } from 'drizzle-orm/libsql/driver-core';
import { HostInfo } from '~/types';
import log from '~/utils/log';
import { HeadplaneConfig } from './config/schema';
import { HeadplaneConfig } from './config/config-schema';
import { hostInfo } from './db/schema';
export async function createHeadplaneAgent(
+43 -20
View File
@@ -1,15 +1,13 @@
import { join } from 'node:path';
import { env, versions } from 'node:process';
import { exit, versions } from 'node:process';
import { createHonoServer } from 'react-router-hono-server/node';
import log from '~/utils/log';
import { configureConfig, configureLogger, envVariables } from './config/env';
import { loadIntegration } from './config/integration';
import { loadConfig } from './config/loader';
import { loadConfig } from './config/load';
import { createDbClient } from './db/client.server';
import { createApiClient } from './headscale/api-client';
import { createHeadscaleInterface } from './headscale/api';
import { loadHeadscaleConfig } from './headscale/config-loader';
import { createHeadplaneAgent } from './hp-agent';
import { configureOidcAuth } from './web/oidc';
import { createSessionStorage } from './web/sessions';
declare global {
@@ -21,13 +19,17 @@ declare global {
// 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);
configureLogger(env[envVariables.debugLog]);
const config = await loadConfig(
configureConfig({
loadEnv: env[envVariables.envOverrides],
path: env[envVariables.configPath],
}),
);
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 agents = await createHeadplaneAgent(
@@ -36,10 +38,25 @@ const agents = await createHeadplaneAgent(
db,
);
const hsApi = await createHeadscaleInterface(
config.headscale.url,
config.headscale.tls_cert_path,
);
// 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 { createOidcConnector } from './web/oidc-connector';
declare module 'react-router' {
interface AppLoadContext extends LoadContext {}
}
const appLoadContext = {
config,
hs: await loadHeadscaleConfig(
@@ -56,19 +73,20 @@ const appLoadContext = {
cookie: {
name: '_hp_auth',
secure: config.server.cookie_secure,
maxAge: 60 * 60 * 24, // 24 hours
// domain: config.server.cookie_domain,
maxAge: config.server.cookie_max_age,
domain: config.server.cookie_domain,
},
}),
client: await createApiClient(
config.headscale.url,
config.headscale.tls_cert_path,
),
hsApi,
agents,
integration: await loadIntegration(config.integration),
oidc: config.oidc ? await configureOidcAuth(config.oidc) : undefined,
oidcConnector: config.oidc
? await createOidcConnector(
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
: undefined,
db,
};
@@ -86,6 +104,11 @@ export default createHonoServer({
});
},
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.
+197
View File
@@ -0,0 +1,197 @@
import * as oidc from 'openid-client';
import log from '~/utils/log';
import type { HeadplaneConfig } from '../config/config-schema';
import type { RuntimeApiClient } from '../headscale/api/endpoints';
import { isDataUnauthorizedError } from '../headscale/api/error-client';
export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
/**
* Errors that can occur during OIDC connector setup and validation.
*/
export type OidcConnectorError =
| 'INVALID_API_KEY'
| 'MISSING_AUTHORIZATION_ENDPOINT'
| 'MISSING_TOKEN_ENDPOINT'
| 'MISSING_USERINFO_ENDPOINT'
| 'MISSING_REQUIRED_CLAIMS'
| 'UNKNOWN_ERROR';
/**
* Represents a "configured" OIDC setup for Headplane.
* This may include mis-configured versions too and will surface error messages.
*/
export type OidcConnector =
| {
isValid: true;
isExclusive: boolean;
client: oidc.Configuration;
apiKey: string;
scope: string;
extraParams?: Record<string, string>;
}
| {
isValid: false;
isExclusive: false;
errors: OidcConnectorError[];
};
/**
* Creates an OIDC connector based on the configuration and Headscale API.
* This will attempt to validate the configuration and return any errors.
*
* @param config The OIDC configuration.
* @param client The Headscale runtime API client.
* @returns An OIDC connector with validation status.
*/
export async function createOidcConnector(
config: OidcConfig,
client: RuntimeApiClient,
): Promise<OidcConnector> {
// TODO: MEANINGFUL LOGS NOT JUST DEBUG SPAM LOL
//
const errors: OidcConnectorError[] = [];
if (!config.headscale_api_key) {
errors.push('INVALID_API_KEY');
return {
isValid: false,
isExclusive: false,
errors,
};
}
try {
await client.getApiKeys();
} catch (error) {
if (isDataUnauthorizedError(error)) {
errors.push('INVALID_API_KEY');
return {
isValid: false,
isExclusive: false,
errors,
};
}
// MARK: Otherwise assume the API key is valid since the API request
// failed for another reason that isn't 401 and we are optimistic
}
const oidcClientOrErrors = await discoveryCoalesce(config);
if (Array.isArray(oidcClientOrErrors)) {
errors.push(...oidcClientOrErrors);
return {
isValid: false,
isExclusive: false,
errors,
};
}
return {
isValid: true,
isExclusive: config.disable_api_key_login,
client: oidcClientOrErrors,
apiKey: config.headscale_api_key,
scope: config.scope,
extraParams: config.extra_params,
};
}
/**
* Runs OIDC discovery and coalesces the results with the provided config.
* We treat the manually supplied values as overrides to discovery.
*
* @param config The OIDC configuration.
* @returns The coalesced OIDC configuration or an array of errors.
*/
async function discoveryCoalesce(
config: OidcConfig,
): Promise<oidc.Configuration | OidcConnectorError[]> {
let metadata: oidc.ServerMetadata;
try {
const client = await oidc.discovery(
new URL(config.issuer),
config.client_id,
);
metadata = client.serverMetadata();
if (metadata.claims_supported != null) {
if (!metadata.claims_supported.includes('sub')) {
log.error('config', 'OIDC provider does not support `sub` claim');
return ['MISSING_REQUIRED_CLAIMS'];
}
if (!metadata.claims_supported.includes('name')) {
if (
!(
metadata.claims_supported.includes('given_name') &&
metadata.claims_supported.includes('family_name')
)
) {
log.warn(
'config',
'OIDC provider does not support `name`, `given_name`, or `family_name` claims',
);
}
}
if (
!metadata.claims_supported.includes('preferred_username') &&
!metadata.claims_supported.includes('email')
) {
log.warn(
'config',
'OIDC provider does not support `preferred_username` or `email` claims',
);
}
}
} catch {
log.error(
'config',
'Failed to auto-configure OIDC endpoints via discovery',
);
log.warn(
'config',
'OIDC server may not support discovery, using manual config',
);
metadata = {
issuer: config.issuer,
};
}
const errors: OidcConnectorError[] = [];
const authorization_endpoint =
config.authorization_endpoint ?? metadata.authorization_endpoint;
const token_endpoint = config.token_endpoint ?? metadata.token_endpoint;
const userinfo_endpoint =
config.userinfo_endpoint ?? metadata.userinfo_endpoint;
if (!authorization_endpoint) {
errors.push('MISSING_AUTHORIZATION_ENDPOINT');
}
if (!token_endpoint) {
errors.push('MISSING_TOKEN_ENDPOINT');
}
if (!userinfo_endpoint) {
errors.push('MISSING_USERINFO_ENDPOINT');
}
if (errors.length > 0) {
return errors;
}
const oidcClient = new oidc.Configuration(
{
issuer: config.issuer,
authorization_endpoint,
token_endpoint,
userinfo_endpoint,
},
config.client_id,
config.client_secret,
);
return oidcClient;
}
-157
View File
@@ -1,157 +0,0 @@
import * as oidc from 'openid-client';
import log from '~/utils/log';
import { HeadplaneConfig } from '../config/schema';
export type OidcConfig = NonNullable<HeadplaneConfig['oidc']>;
export async function configureOidcAuth(config: OidcConfig) {
log.debug('config', 'Running OIDC discovery for %s', config.issuer);
let clientAuthMethod: oidc.ClientAuth;
switch (config.token_endpoint_auth_method) {
case 'client_secret_basic':
clientAuthMethod = oidc.ClientSecretBasic(config.client_secret!);
break;
case 'client_secret_post':
clientAuthMethod = oidc.ClientSecretPost(config.client_secret!);
break;
case 'client_secret_jwt':
clientAuthMethod = oidc.ClientSecretJwt(config.client_secret!);
break;
default:
throw new Error('Invalid client authentication method');
}
let oidcClient: oidc.Configuration;
try {
const discovery = await oidc.discovery(
new URL(config.issuer),
config.client_id,
config.client_secret!, // TODO: Fix this config schema
clientAuthMethod,
);
const meta = discovery.serverMetadata();
if (!meta.authorization_endpoint) {
log.error(
'config',
'Issuer discovery did not return `authorization_endpoint`',
);
log.error(
'config',
'OIDC server does not support authorization code flow',
);
log.error('config', 'You may need to set this manually in the config');
return;
}
if (!meta.token_endpoint) {
log.error('config', 'Issuer discovery did not return `token_endpoint`');
log.error(
'config',
'OIDC server does not support authorization code flow',
);
log.error('config', 'You may need to set this manually in the config');
return;
}
if (!meta.userinfo_endpoint) {
log.error(
'config',
'Issuer discovery did not return `userinfo_endpoint`',
);
log.error('config', 'OIDC server does not support user info endpoint');
log.error('config', 'You may need to set this manually in the config');
return;
}
if (meta.token_endpoint_auth_methods_supported) {
if (
!meta.token_endpoint_auth_methods_supported.includes(
config.token_endpoint_auth_method,
)
) {
log.error(
'config',
'OIDC server does not support client authentication method %s',
config.token_endpoint_auth_method,
);
log.error(
'config',
'Supported methods: %s',
meta.token_endpoint_auth_methods_supported.join(', '),
);
return;
}
}
log.debug('config', 'OIDC discovery successful');
log.debug(
'config',
'Authorization endpoint: %s',
meta.authorization_endpoint,
);
log.debug('config', 'Token endpoint: %s', meta.token_endpoint);
log.debug('config', 'Userinfo endpoint: %s', meta.userinfo_endpoint);
// Manually construct the endpoints to coalesce with config if needed
oidcClient = new oidc.Configuration(
{
issuer: config.issuer,
authorization_endpoint:
config.authorization_endpoint || meta.authorization_endpoint,
token_endpoint: config.token_endpoint || meta.token_endpoint,
userinfo_endpoint: config.userinfo_endpoint || meta.userinfo_endpoint,
},
config.client_id,
config.client_secret!,
clientAuthMethod,
);
} catch (err) {
log.error('config', 'OIDC discovery failed: %s', err);
log.debug('config', 'Error details: %o', err);
log.error(
'config',
'This may be an error, or the server may not support discovery',
);
if (
!config.authorization_endpoint ||
!config.token_endpoint ||
!config.userinfo_endpoint
) {
log.error(
'config',
'Endpoints are not fully configured, cannot continue',
);
log.error(
'config',
'You must set authorization_endpoint, token_endpoint and userinfo_endpoint manually in the config or fix the discovery issue',
);
return;
}
oidcClient = new oidc.Configuration(
{
issuer: config.issuer,
authorization_endpoint: config.authorization_endpoint,
token_endpoint: config.token_endpoint,
userinfo_endpoint: config.userinfo_endpoint,
},
config.client_id,
config.client_secret!,
clientAuthMethod,
);
log.debug('config', 'Using manually configured endpoints');
log.debug(
'config',
'Authorization endpoint: %s',
config.authorization_endpoint,
);
log.debug('config', 'Token endpoint: %s', config.token_endpoint);
log.debug('config', 'Userinfo endpoint: %s', config.userinfo_endpoint);
}
log.info('config', 'Successfully configured OIDC authentication');
return oidcClient;
}
+13 -20
View File
@@ -152,13 +152,14 @@ class Sessionizer {
}
async function createSession(payload: JWTSession, options: AuthSessionOptions) {
const now = Math.floor(Date.now() / 1000);
const secret = createHash('sha256').update(options.secret, 'utf8').digest();
const jwt = await new EncryptJWT({
...payload,
})
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' })
.setIssuedAt()
.setExpirationTime('1d')
.setExpirationTime(now + options.cookie.maxAge)
.setIssuer('urn:tale:headplane')
.setAudience('urn:tale:headplane')
.setJti(ulid())
@@ -218,36 +219,28 @@ export async function createSessionStorage(options: AuthSessionOptions) {
}
async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
log.info('config', 'Migrating old user database from %s', path);
const realPath = resolve(path);
log.warn(
'config',
'oidc.user_storage_file is deprecated and will be removed in Headplane 0.7.0',
);
log.warn(
'config',
'You can ignore this warning if you do not use OIDC authentication.',
);
log.warn(
'config',
'Data will be automatically migrated to the new SQL database.',
);
log.warn(
'config',
'Refer to server.data_path to ensure this path is mounted correctly is using Docker.',
);
try {
const handle = await open(realPath, 'a+');
await handle.close();
} catch (error) {
if (
error != null &&
typeof error === 'object' &&
'code' in error &&
error.code === 'ENOENT'
) {
log.debug('config', 'No old user database file found at %s', realPath);
return;
}
log.warn('config', 'Failed to migrate old user database at %s', realPath);
log.warn(
'config',
'This is not an error, but existing users will not be migrated',
);
log.warn('config', 'Unable to open user database file: %s', error);
log.warn('config', 'Unable to open user database file: %s', String(error));
log.debug('config', 'Error details: %s', error);
return;
}
+19 -2
View File
@@ -14,10 +14,12 @@ export interface Logger
debugEnabled: boolean;
}
const logLevels = getLogLevels();
export default {
debugEnabled: true,
debugEnabled: logLevels.includes('debug'),
debug: (..._: Parameters<Logger['debug']>) => {},
...Object.fromEntries(
levels.map((level) => [
logLevels.map((level) => [
level,
(category: Category, message: string, ...args: unknown[]) => {
const date = new Date().toISOString();
@@ -29,3 +31,18 @@ export default {
]),
),
} as Logger;
function getLogLevels() {
const debugLog = process.env.HEADPLANE_DEBUG_LOG;
if (debugLog == null) {
return ['info', 'warn', 'error'];
}
const normalized = debugLog.trim().toLowerCase();
const truthyValues = ['1', 'true', 'yes', 'on'];
if (!truthyValues.includes(normalized)) {
return ['info', 'warn', 'error'];
}
return ['info', 'warn', 'error', 'debug'];
}
+11
View File
@@ -52,3 +52,14 @@ export function mapNodes(
};
});
}
export function sortNodeTags(nodes: Machine[]): string[] {
return Array.from(
new Set(
nodes.flatMap(({ validTags, forcedTags }) => [
...validTags,
...forcedTags,
]),
),
).sort();
}
-204
View File
@@ -1,204 +0,0 @@
import * as client from 'openid-client';
import { Configuration, IDToken, UserInfoResponse } from 'openid-client';
import log from '~/utils/log';
// We try our best to infer the callback URI of our Headplane instance
// By default it is always /<base_path>/oidc/callback
// (This can ALWAYS be overridden through the OidcConfig)
export function getRedirectUri(req: Request) {
const base = __PREFIX__ ?? '/admin'; // Fallback
const url = new URL(`${base}/oidc/callback`, req.url);
let host = req.headers.get('Host');
if (!host) {
host = req.headers.get('X-Forwarded-Host');
}
if (!host) {
log.error('auth', 'Unable to find a host header');
log.error('auth', 'Ensure either Host or X-Forwarded-Host is set');
throw new Error('Could not determine reverse proxy host');
}
const proto = req.headers.get('X-Forwarded-Proto');
if (!proto) {
log.warn('auth', 'No X-Forwarded-Proto header found');
log.warn('auth', 'Assuming your Headplane instance runs behind HTTP');
}
url.protocol = proto ?? 'http:';
url.host = host;
return url.href;
}
export async function beginAuthFlow(
config: Configuration,
redirect_uri: string,
scope: string,
extra_params: Record<string, string> = {},
) {
const codeVerifier = client.randomPKCECodeVerifier();
const codeChallenge = await client.calculatePKCECodeChallenge(codeVerifier);
const params: Record<string, string> = {
...extra_params,
scope,
redirect_uri,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
state: client.randomState(),
};
if (!config.serverMetadata().supportsPKCE()) {
params.nonce = client.randomNonce();
}
const url = client.buildAuthorizationUrl(config, params);
return {
url: url.href,
codeVerifier,
state: params.state,
nonce: params.nonce ?? '<none>',
};
}
interface FlowOptions {
redirect_uri: string;
code_verifier: string;
state: string;
nonce?: string;
}
export interface FlowUser {
subject: string;
name: string;
email: string | undefined;
username: string | undefined;
picture: string | undefined;
}
export async function finishAuthFlow(
config: Configuration,
options: FlowOptions,
): Promise<FlowUser> {
const tokens = await client.authorizationCodeGrant(
config,
new URL(options.redirect_uri),
{
pkceCodeVerifier: options.code_verifier,
expectedNonce: options.nonce,
expectedState: options.state,
idTokenExpected: true,
},
);
const claims = tokens.claims();
if (!claims?.sub) {
throw new Error('No subject found in OIDC claims');
}
const user = await client.fetchUserInfo(
config,
tokens.access_token,
claims.sub,
);
return {
subject: user.sub,
name: getName(user, claims),
email: user.email ?? claims.email?.toString(),
username: calculateUsername(claims, user),
picture: user.picture,
};
}
function calculateUsername(claims: IDToken, user: UserInfoResponse) {
if (user.preferred_username) {
return user.preferred_username;
}
if (
claims.preferred_username &&
typeof claims.preferred_username === 'string'
) {
return claims.preferred_username;
}
if (user.email) {
return user.email.split('@')[0];
}
if (claims.email && typeof claims.email === 'string') {
return claims.email.split('@')[0];
}
return;
}
function getName(user: client.UserInfoResponse, claims: client.IDToken) {
if (user.name) {
return user.name;
}
if (claims.name && typeof claims.name === 'string') {
return claims.name;
}
if (user.given_name && user.family_name) {
return `${user.given_name} ${user.family_name}`;
}
if (user.preferred_username) {
return user.preferred_username;
}
if (
claims.preferred_username &&
typeof claims.preferred_username === 'string'
) {
return claims.preferred_username;
}
return 'Anonymous';
}
export function formatError(error: unknown) {
if (error instanceof client.ResponseBodyError) {
return {
code: error.code,
error: {
name: error.error,
description: error.error_description,
},
};
}
if (error instanceof client.AuthorizationResponseError) {
return {
code: error.code,
error: {
name: error.error,
description: error.error_description,
},
};
}
if (error instanceof client.WWWAuthenticateChallengeError) {
return {
code: error.code,
error: {
name: error.name,
description: error.message,
challenges: error.cause,
},
};
}
log.error('auth', 'Unknown error: %s', error);
return {
code: 500,
error: {
name: 'Internal Server Error',
description: 'An unknown error occurred',
},
};
}
+4
View File
@@ -104,6 +104,10 @@ if [ "$SKIP_PATH_CHECKS" -eq 0 ]; then
fi
if [ $need_pnpm -eq 1 ]; then
echo "==> Checking for node"
command -v node >/dev/null 2>&1 || die "node not installed"
node --version >/dev/null 2>&1 || die "node not working"
echo "==> Checking for pnpm"
command -v pnpm >/dev/null 2>&1 || die "pnpm not installed"
pnpm --version >/dev/null 2>&1 || die "pnpm not working"
+1 -1
View File
@@ -24,7 +24,7 @@ services:
- "./test/caddy/config:/config"
- "./test/caddy/certs:/certs"
headscale:
image: "headscale/headscale:0.26.0-debug"
image: "headscale/headscale:0.27.0-debug"
container_name: "headscale"
labels:
me.tale.headplane.target: headscale
+57 -45
View File
@@ -1,17 +1,27 @@
# Configuration for the Headplane server and web application
server:
# These are the default values, change them as needed
host: "0.0.0.0"
port: 3000
# The secret used to encode and decode web sessions
# Ensure that this is exactly 32 characters long
# The secret used to encode and decode web sessions (must be 32 characters)
# You may also provide `cookie_secret_path` instead to read a value from disk.
# See https://headplane.net/configuration/#sensitive-values
cookie_secret: "<change_me_to_something_secure!>"
# Should the cookies only work over HTTPS?
# Set to false if running via HTTP without a proxy
# (I recommend this is true in production)
# Whether cookies should be marked as Secure
# * Should be false if running without HTTPs
# * Should be true if running behind a reverse proxy with HTTPs
cookie_secure: true
# The maximum age of the session cookie in seconds
cookie_max_age: 86400 # 1 day in seconds
# This is not required, but if you want to restrict the cookie
# to a specific domain, set it here. Otherwise leave it commented out.
# This may not work as expected if not using a reverse proxy.
# cookie_domain: ""
# The path to persist Headplane specific data. All data going forward
# is stored in this directory, including the internal database and
# any cache related files.
@@ -32,11 +42,11 @@ headscale:
# If you use the TLS configuration in Headscale, and you are not using
# Let's Encrypt for your certificate, pass in the path to the certificate.
# (This has no effect `url` does not start with `https://`)
# (This has no effect if `url` does not start with `https://`)
# tls_cert_path: "/var/lib/headplane/tls.crt"
# Optional, public URL if they differ
# This affects certain parts of the web UI
# Optional, public URL if its different from the `headscale.url`
# This affects certain parts of the web UI which shows Headscale's URL
# public_url: "https://headscale.example.com"
# Path to the Headscale configuration file
@@ -45,9 +55,13 @@ headscale:
# in the Web UI, but they cannot be changed.
config_path: "/etc/headscale/config.yaml"
# Headplane internally validates the Headscale configuration
# to ensure that it changes the configuration in a safe way.
# If you want to disable this validation, set this to false.
# Whether the Headscale configuration should be strictly validated
# when reading from `config_path`. If true, Headplane will not interact
# with Headscale if there are any issues with the configuration file.
#
# This is recommended to be true for production deployments to, however it
# may not work if you are using a version of Headscale that has configuration
# options unknown to Headplane.
config_strict: true
# If you are using `dns.extra_records_path` in your Headscale
@@ -58,19 +72,21 @@ headscale:
# readable and writable to the Headplane process.
# When using this, Headplane will no longer need to automatically
# restart Headscale for DNS record changes.
# dns_records_path: "/var/lib/headplane/extra_records.json"
# dns_records_path: "/var/lib/headscale/extra_records.json"
# Integration configurations for Headplane to interact with Headscale
integration:
# The Headplane agent allows retrieving information about nodes
# This allows the UI to display version, OS, and connectivity data
# You will see the Headplane agent in your Tailnet as a node when
# it connects.
agent:
# The Headplane agent allows retrieving information about nodes
# This allows the UI to display version, OS, and connectivity data
# You will see the Headplane agent in your Tailnet as a node when
# it connects.
enabled: false
# To connect to your Tailnet, you need to generate a pre-auth key
# This can be done via the web UI or through the `headscale` CLI.
pre_authkey: "<your-preauth-key>"
# Optionally change the name of the agent in the Tailnet.
# host_name: "headplane-agent"
@@ -81,10 +97,11 @@ integration:
# cache_ttl: 60
# cache_path: /var/lib/headplane/agent_cache.json
# Do not change this unless you are running a custom deployment.
# The work_dir represents where the agent will store its data to be able
# to automatically reauthenticate with your Tailnet. It needs to be
# writable by the user running the Headplane process.
#
# If using Docker, it is best to leave this as the default.
# work_dir: "/var/lib/headplane/agent"
# Only one of these should be enabled at a time or you will get errors
@@ -132,60 +149,55 @@ integration:
proc:
enabled: false
# OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience)
oidc:
issuer: "https://accounts.google.com"
# OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience)
# oidc:
# The OIDC issuer URL
# issuer: "https://accounts.google.com"
# If you are using OIDC, you need to generate an API key
# that can be used to authenticate other sessions when signing in.
#
# This can be done with `headscale apikeys create --expiration 999d`
# 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 the documentation.
# For some typical providers, see https://headplane.net/features/sso.
# authorization_endpoint: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# 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. A lot of OIDC providers will use different
# subjects per user based on the client_id and for this to work correctly
# they need to be identical between Headscale and Headplane.
client_id: "your-client-id"
# you are using for Headscale. because
# client_id: "your-client-id"
# The client secret for the OIDC client
# Either this or `client_secret_path` must be set for OIDC to work
client_secret: "<your-client-secret>"
# You can alternatively set `client_secret_path` to read the secret from disk.
# The path specified can resolve environment variables, making integration
# with systemd's `LoadCredential` straightforward:
# client_secret_path: "${CREDENTIALS_DIRECTORY}/oidc_client_secret"
# 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>"
# Defaults to 'openid email profile'
# scope: "openid email profile"
disable_api_key_login: false
token_endpoint_auth_method: "client_secret_post"
# If you are using OIDC, you need to generate an API key
# that can be used to authenticate other sessions when signing in.
#
# This can be done with `headscale apikeys create --expiration 999d`
headscale_api_key: "<your-headscale-api-key>"
# If you want to disable traditional login via Headscale API keys
# disable_api_key_login: false
# Optional, but highly recommended otherwise Headplane
# will attempt to automatically guess this from the issuer
#
# This should point to your publicly accessibly URL
# for your Headplane instance with /admin/oidc/callback
redirect_uri: "http://localhost:3000/admin/oidc/callback"
# redirect_uri: "http://localhost:3000/admin/oidc/callback"
# 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.

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