Compare commits

...

46 Commits

Author SHA1 Message Date
Aarnav Tale 3a3e5ca65e feat: pkce 2025-12-04 11:37:22 -05:00
Aarnav Tale 3eff763436 docs: create initial sso page 2025-12-04 10:52:16 -05:00
Aarnav Tale 6ad0847653 fix: the login page would crash if OIDC is not enabled correctly 2025-12-04 10:39:24 -05:00
Aarnav Tale 199ef46ee1 fix: correctly passthrough and handle a new OIDC redirect_uri system 2025-12-04 10:34:48 -05:00
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
134 changed files with 7647 additions and 5061 deletions
+20 -26
View File
@@ -2,14 +2,12 @@ name: Build
on: on:
push: push:
paths-ignore: paths-ignore:
- ".zed/**"
- "assets/**" - "assets/**"
- "docs/**" - "docs/**"
- "CHANGELOG.md" - "CHANGELOG.md"
- "README.md" - "README.md"
branches: branches:
- "main" - "main"
- "next"
pull_request: pull_request:
concurrency: concurrency:
@@ -21,42 +19,38 @@ permissions:
contents: read # Read access to the repository contents: read # Read access to the repository
jobs: jobs:
native: ci:
name: native name: Build and Test
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Check out the repo - name: Check out the repo
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Mise - name: Setup pnpm
uses: jdx/mise-action@v2 uses: pnpm/action-setup@v4
- 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
with: with:
path: ${{ env.STORE_PATH }} run_install: false
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Setup Go cache - name: Setup Node.js
uses: actions/cache@v4 uses: actions/setup-node@v6
with: with:
path: | node-version-file: package.json
${{ env.GO_CACHE }} cache: pnpm
${{ env.GO_MODCACHE }}
key: ${{ runner.os }}-go-${{ hashFiles('**/go.mod', '**/go.sum') }} - name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod
- name: CI pipeline - name: CI pipeline
run: ./build.sh --skip-pnpm-prune 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: nix:
name: nix name: nix
runs-on: ubuntu-latest runs-on: ubuntu-latest
+14 -1
View File
@@ -1,8 +1,21 @@
# 0.6.2 (Next) # 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)). - 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)). - 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). - 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.
- Deprecated `oidc.redirect_uri` and automated callback URL detection in favor of setting `server.base_url` correctly.
- Explicitly added `oidc.use_pkce` to correctly determine PKCE configuration.
- 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) # 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> { export interface CodeProps extends HTMLProps<HTMLSpanElement> {
isCopyable?: boolean; isCopyable?: boolean;
children: string | string[]; children: string | string[] | number;
} }
export default function Code({ isCopyable, children, className }: CodeProps) { 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)}> <div className={cn('flex flex-col', props.className)}>
<label <label
{...labelProps} {...labelProps}
htmlFor={id}
className={cn( className={cn(
'text-xs font-medium px-3 mb-0.5', 'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100', 'text-headplane-700 dark:text-headplane-100',
)} )}
htmlFor={id}
> >
{props.label} {props.label}
</label> </label>
@@ -63,18 +63,18 @@ function Select(props: SelectProps) {
'flex rounded-xl focus:outline-hidden focus-within:ring-3', 'flex rounded-xl focus:outline-hidden focus-within:ring-3',
'bg-white dark:bg-headplane-900', 'bg-white dark:bg-headplane-900',
'border border-headplane-100 dark:border-headplane-800', 'border border-headplane-100 dark:border-headplane-800',
props.isInvalid && 'ring-red-400',
)} )}
> >
<input <input
{...inputProps} {...inputProps}
ref={inputRef}
id={id}
className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent" className="outline-hidden px-3 py-2 rounded-l-xl w-full bg-transparent"
data-1p-ignore data-1p-ignore
id={id}
ref={inputRef}
/> />
<button <button
{...buttonProps} {...buttonProps}
ref={buttonRef}
className={cn( className={cn(
'flex items-center justify-center p-1 rounded-lg m-1', 'flex items-center justify-center p-1 rounded-lg m-1',
'bg-headplane-100 dark:bg-headplane-700/30 font-medium', 'bg-headplane-100 dark:bg-headplane-700/30 font-medium',
@@ -82,6 +82,7 @@ function Select(props: SelectProps) {
? 'opacity-50 cursor-not-allowed' ? 'opacity-50 cursor-not-allowed'
: 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30', : 'hover:bg-headplane-200/90 dark:hover:bg-headplane-800/30',
)} )}
ref={buttonRef}
> >
<ChevronDown className="p-0.5" /> <ChevronDown className="p-0.5" />
</button> </button>
@@ -99,12 +100,12 @@ function Select(props: SelectProps) {
)} )}
{state.isOpen && ( {state.isOpen && (
<Popover <Popover
popoverRef={popoverRef} className="w-full max-w-xs"
triggerRef={inputRef}
state={state}
isNonModal isNonModal
placement="bottom start" placement="bottom start"
className="w-full max-w-xs" popoverRef={popoverRef}
state={state}
triggerRef={inputRef}
> >
<ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} /> <ListBox {...listBoxProps} listBoxRef={listBoxRef} state={state} />
</Popover> </Popover>
@@ -114,23 +115,22 @@ function Select(props: SelectProps) {
} }
interface ListBoxProps extends AriaListBoxOptions<object> { interface ListBoxProps extends AriaListBoxOptions<object> {
listBoxRef?: React.RefObject<HTMLUListElement | null>; listBoxRef: React.RefObject<HTMLUListElement | null>;
state: ListState<object>; state: ListState<object>;
} }
function ListBox(props: ListBoxProps) { function ListBox(props: ListBoxProps) {
const { listBoxRef, state } = props; const { listBoxRef, state } = props;
const ref = listBoxRef ?? useRef<HTMLUListElement | null>(null); const { listBoxProps } = useListBox(props, state, listBoxRef);
const { listBoxProps } = useListBox(props, state, ref);
return ( return (
<ul <ul
{...listBoxProps} {...listBoxProps}
ref={listBoxRef}
className="w-full max-h-72 overflow-auto outline-hidden pt-1" className="w-full max-h-72 overflow-auto outline-hidden pt-1"
ref={listBoxRef}
> >
{[...state.collection].map((item) => ( {[...state.collection].map((item) => (
<Option key={item.key} item={item} state={state} /> <Option item={item} key={item.key} state={state} />
))} ))}
</ul> </ul>
); );
@@ -154,7 +154,6 @@ function Option({ item, state }: OptionProps) {
return ( return (
<li <li
{...optionProps} {...optionProps}
ref={ref}
className={cn( className={cn(
'flex items-center justify-between', 'flex items-center justify-between',
'py-2 px-3 mx-1 rounded-lg mb-1', 'py-2 px-3 mx-1 rounded-lg mb-1',
@@ -164,6 +163,7 @@ function Option({ item, state }: OptionProps) {
: 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800', : 'hover:bg-headplane-100/50 dark:hover:bg-headplane-800',
isDisabled && 'text-headplane-300 dark:text-headplane-600', isDisabled && 'text-headplane-300 dark:text-headplane-600',
)} )}
ref={ref}
> >
{item.rendered} {item.rendered}
{isSelected && <Check className="p-0.5" />} {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 { hydrateRoot } from 'react-dom/client';
import { HydratedRouter } from 'react-router/dom'; import { HydratedRouter } from 'react-router/dom';
if (import.meta.env.DEV) {
import('react-scan').then(({ scan }) => {
scan({ enabled: true });
});
}
startTransition(() => { startTransition(() => {
hydrateRoot( hydrateRoot(
document, document,
+2 -1
View File
@@ -3,7 +3,8 @@ import { createReadableStreamFromReadable } from '@react-router/node';
import { isbot } from 'isbot'; import { isbot } from 'isbot';
import type { RenderToPipeableStreamOptions } from 'react-dom/server'; import type { RenderToPipeableStreamOptions } from 'react-dom/server';
import { renderToPipeableStream } 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 const streamTimeout = 5_000;
export default function handleRequest( export default function handleRequest(
+22 -18
View File
@@ -1,27 +1,27 @@
import { type LoaderFunctionArgs, Outlet, redirect } from 'react-router'; import { Outlet, redirect } from 'react-router';
import { ErrorPopup } from '~/components/Error'; import { ErrorBanner } from '~/components/error-banner';
import type { LoadContext } from '~/server';
import { pruneEphemeralNodes } from '~/server/db/pruner'; 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 log from '~/utils/log';
import type { Route } from './+types/dashboard';
export async function loader({ export async function loader({ request, context, ...rest }: Route.LoaderArgs) {
request,
context,
...rest
}: LoaderFunctionArgs<LoadContext>) {
const healthy = await context.client.healthcheck();
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
await pruneEphemeralNodes({ context, request, ...rest }); const api = context.hsApi.getRuntimeClient(session.api_key);
// We shouldn't session invalidate if Headscale is down // MARK: The session should stay valid if Headscale isn't healthy
// TODO: Notify in the logs or the UI that OIDC auth key is wrong if enabled const healthy = await api.isHealthy();
if (healthy) { if (healthy) {
try { try {
await context.client.get('v1/apikey', session.api_key); await api.getApiKeys();
await pruneEphemeralNodes({ context, request, ...rest });
} catch (error) { } catch (error) {
if (error instanceof ResponseError) { if (isDataUnauthorizedError(error)) {
log.debug('api', 'API Key validation failed %o', error); log.warn(
'auth',
'Logging out %s due to expired API key',
session.user.name,
);
return redirect('/login', { return redirect('/login', {
headers: { headers: {
'Set-Cookie': await context.sessions.destroySession(), 'Set-Cookie': await context.sessions.destroySession(),
@@ -44,6 +44,10 @@ export default function Layout() {
); );
} }
export function ErrorBoundary() { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return <ErrorPopup type="embedded" />; 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 { eq } from 'drizzle-orm';
import { CircleCheckIcon } from 'lucide-react'; import { CircleCheckIcon } from 'lucide-react';
import { import { Outlet, redirect } from 'react-router';
LoaderFunctionArgs,
Outlet,
redirect,
useLoaderData,
} from 'react-router';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Card from '~/components/Card'; import Card from '~/components/Card';
import Footer from '~/components/Footer'; import Footer from '~/components/Footer';
import Header from '~/components/Header'; import Header from '~/components/Header';
import type { LoadContext } from '~/server';
import { users } from '~/server/db/schema'; import { users } from '~/server/db/schema';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import toast from '~/utils/toast'; import toast from '~/utils/toast';
import { Route } from './+types/shell';
// This loads the bare minimum for the application to function // This loads the bare minimum for the application to function
// So we know that if context fails to load then well, oops? // So we know that if context fails to load then well, oops?
export async function loader({ export async function loader({ request, context }: Route.LoaderArgs) {
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try { try {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
if ( if (
context.oidc && typeof context.oidc === 'object' &&
session.user.subject !== 'unknown-non-oauth' && session.user.subject !== 'unknown-non-oauth' &&
!request.url.endsWith('/onboarding') !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); const check = await context.sessions.check(request, Capabilities.ui_access);
return { return {
config: context.hs.c, config: context.hs.c,
@@ -62,7 +55,7 @@ export async function loader({
), ),
}, },
onboarding: request.url.endsWith('/onboarding'), onboarding: request.url.endsWith('/onboarding'),
healthy: await context.client.healthcheck(), healthy: await api.isHealthy(),
}; };
} catch { } catch {
return redirect('/login', { return redirect('/login', {
@@ -73,14 +66,12 @@ export async function loader({
} }
} }
export default function Shell() { export default function Shell({ loaderData }: Route.ComponentProps) {
const data = useLoaderData<typeof loader>();
return ( return (
<> <>
<Header {...data} /> <Header {...loaderData} />
{/* Always show the outlet if we are onboarding */} {/* Always show the outlet if we are onboarding */}
{(data.onboarding ? true : data.uiAccess) ? ( {(loaderData.onboarding ? true : loaderData.uiAccess) ? (
<Outlet /> <Outlet />
) : ( ) : (
<Card className="mx-auto w-fit mt-24"> <Card className="mx-auto w-fit mt-24">
@@ -96,13 +87,13 @@ export default function Shell() {
className="flex text-md font-mono" className="flex text-md font-mono"
onPress={async () => { onPress={async () => {
await navigator.clipboard.writeText( await navigator.clipboard.writeText(
`tailscale up --login-server=${data.url}`, `tailscale up --login-server=${loaderData.url}`,
); );
toast('Copied to clipboard'); toast('Copied to clipboard');
}} }}
> >
tailscale up --login-server={data.url} tailscale up --login-server={loaderData.url}
</Button> </Button>
<p className="text-xs mt-1 opacity-50 text-center"> <p className="text-xs mt-1 opacity-50 text-center">
Click this button to copy the command. Click this button to copy the command.
@@ -113,7 +104,7 @@ export default function Shell() {
</p> </p>
</Card> </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'; } from 'react-router';
import '@fontsource-variable/inter'; import '@fontsource-variable/inter';
import { ExternalScripts } from 'remix-utils/external-scripts'; import { ExternalScripts } from 'remix-utils/external-scripts';
import { ErrorPopup } from '~/components/Error';
import ProgressBar from '~/components/ProgressBar'; import ProgressBar from '~/components/ProgressBar';
import ToastProvider from '~/components/ToastProvider'; import ToastProvider from '~/components/ToastProvider';
import stylesheet from '~/tailwind.css?url'; import stylesheet from '~/tailwind.css?url';
import { LiveDataProvider } from '~/utils/live-data'; import { LiveDataProvider } from '~/utils/live-data';
import { useToastQueue } from '~/utils/toast'; import { useToastQueue } from '~/utils/toast';
import type { Route } from './+types/root';
import { ErrorBanner } from './components/error-banner';
export const meta: MetaFunction = () => [ export const meta: MetaFunction = () => [
{ title: 'Headplane' }, { title: 'Headplane' },
@@ -56,8 +57,12 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
); );
} }
export function ErrorBoundary() { export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
return <ErrorPopup />; 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() { export default function App() {
+13 -18
View File
@@ -1,40 +1,35 @@
import { ActionFunctionArgs, data } from 'react-router'; import { data } from 'react-router';
import { LoadContext } from '~/server'; import ResponseError from '~/server/headscale/api/response-error';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles'; 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 // We only check capabilities here and assume it is writable
// If it isn't, it'll gracefully error anyways, since this means some // If it isn't, it'll gracefully error anyways, since this means some
// fishy client manipulation is happening. // fishy client manipulation is happening.
export async function aclAction({ export async function aclAction({ request, context }: Route.ActionArgs) {
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const check = await context.sessions.check( const check = await context.sessions.check(
request, request,
Capabilities.write_policy, Capabilities.write_policy,
); );
if (!check) { 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). // Try to write to the ACL policy via the API or via config file (TODO).
const formData = await request.formData(); const formData = await request.formData();
const policyData = formData.get('policy')?.toString(); const policyData = formData.get('policy')?.toString();
if (!policyData) { 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 { try {
const { policy, updatedAt } = await context.client.put<{ const { policy, updatedAt } = await api.setPolicy(policyData);
policy: string;
updatedAt: string;
}>('v1/policy', session.api_key, {
policy: policyData,
});
return data({ return data({
success: true, success: true,
error: undefined, error: undefined,
@@ -50,7 +45,7 @@ export async function aclAction({
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go // https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('update is disabled')) { if (message.includes('update is disabled')) {
// This means the policy is not writable // 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 // 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 { data } from 'react-router';
import { LoadContext } from '~/server'; import ResponseError from '~/server/headscale/api/response-error';
import ResponseError from '~/server/headscale/api-error';
import { Capabilities } from '~/server/web/roles'; 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 // The logic for deciding policy factors is very complicated because
// there are so many factors that need to be accounted for: // 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? // 3. Is the Headscale policy in file or database mode?
// If database, we can read/write easily via the API. // If database, we can read/write easily via the API.
// If in file mode, we can only write if context.config is available. // 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 }: Route.LoaderArgs) {
export async function aclLoader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_policy); const check = await context.sessions.check(request, Capabilities.read_policy);
if (!check) { 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 = { const flags = {
@@ -30,11 +27,9 @@ export async function aclLoader({
}; };
// Try to load the ACL policy from the API. // Try to load the ACL policy from the API.
const api = context.hsApi.getRuntimeClient(session.api_key);
try { try {
const { policy, updatedAt } = await context.client.get<{ const { policy, updatedAt } = await api.getPolicy();
policy: string;
updatedAt: string | null;
}>('v1/policy', session.api_key);
// Successfully loaded the policy, mark it as readable // Successfully loaded the policy, mark it as readable
// If `updatedAt` is null, it means the policy is in file mode. // If `updatedAt` is null, it means the policy is in file mode.
+8 -8
View File
@@ -38,14 +38,14 @@ export function Editor(props: EditorProps) {
<ClientOnly fallback={<Fallback acl={props.value} />}> <ClientOnly fallback={<Fallback acl={props.value} />}>
{() => ( {() => (
<CodeMirror <CodeMirror
value={props.value} editable={!props.isDisabled}
editable={!props.isDisabled} // Allow editing unless disabled extensions={[shopify.jsonc()]} // Allow editing unless disabled
readOnly={props.isDisabled} // Use readOnly if disabled height="100%" // Use readOnly if disabled
height="100%" onChange={(value) => props.onChange(value)}
extensions={[shopify.jsonc()]} readOnly={props.isDisabled}
style={{ height: '100%' }} style={{ height: '100%' }}
theme={light ? xcodeLight : xcodeDark} theme={light ? xcodeLight : xcodeDark}
onChange={(value) => props.onChange(value)} value={props.value}
/> />
)} )}
</ClientOnly> </ClientOnly>
@@ -92,14 +92,14 @@ export function Differ(props: DifferProps) {
{() => ( {() => (
<Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}> <Merge orientation="a-b" theme={light ? xcodeLight : xcodeDark}>
<Merge.Original <Merge.Original
extensions={[shopify.jsonc()]}
readOnly readOnly
value={props.left} value={props.left}
extensions={[shopify.jsonc()]}
/> />
<Merge.Modified <Merge.Modified
extensions={[shopify.jsonc()]}
readOnly readOnly
value={props.right} value={props.right}
extensions={[shopify.jsonc()]}
/> />
</Merge> </Merge>
)} )}
+1 -1
View File
@@ -23,12 +23,12 @@ export default function Fallback({ acl }: Props) {
/> />
</div> </div>
<textarea <textarea
readOnly
className={cn( className={cn(
'w-full h-editor font-mono resize-none text-sm', 'w-full h-editor font-mono resize-none text-sm',
'bg-headplane-50 dark:bg-headplane-950 opacity-60', 'bg-headplane-50 dark:bg-headplane-950 opacity-60',
'pl-1 pt-1 leading-snug', 'pl-1 pt-1 leading-snug',
)} )}
readOnly
value={acl} value={acl}
/> />
</div> </div>
+13 -24
View File
@@ -1,34 +1,23 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react'; import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import { useFetcher, useRevalidator } from 'react-router';
ActionFunctionArgs,
LoaderFunctionArgs,
useFetcher,
useLoaderData,
useRevalidator,
} from 'react-router';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Link from '~/components/Link'; import Link from '~/components/Link';
import Notice from '~/components/Notice'; import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs'; import Tabs from '~/components/Tabs';
import type { LoadContext } from '~/server';
import toast from '~/utils/toast'; import toast from '~/utils/toast';
import type { Route } from './+types/overview';
import { aclAction } from './acl-action'; import { aclAction } from './acl-action';
import { aclLoader } from './acl-loader'; import { aclLoader } from './acl-loader';
import { Differ, Editor } from './components/cm.client'; import { Differ, Editor } from './components/cm.client';
export async function loader(request: LoaderFunctionArgs<LoadContext>) { export const loader = aclLoader;
return aclLoader(request); export const action = aclAction;
}
export async function action(request: ActionFunctionArgs<LoadContext>) { export default function Page({
return aclAction(request); loaderData: { access, writable, policy },
} }: Route.ComponentProps) {
export default function Page() {
// Access is a write check here, we already check read in aclLoader
const { access, writable, policy } = useLoaderData<typeof loader>();
const [codePolicy, setCodePolicy] = useState(policy); const [codePolicy, setCodePolicy] = useState(policy);
const fetcher = useFetcher<typeof action>(); const fetcher = useFetcher<typeof action>();
const { revalidate } = useRevalidator(); const { revalidate } = useRevalidator();
@@ -75,15 +64,15 @@ export default function Page() {
The ACL file is used to define the access control rules for your 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{' '} network. You can find more information about the ACL file in the{' '}
<Link <Link
to="https://tailscale.com/kb/1018/acls"
name="Tailscale ACL documentation" name="Tailscale ACL documentation"
to="https://tailscale.com/kb/1018/acls"
> >
Tailscale ACL guide Tailscale ACL guide
</Link>{' '} </Link>{' '}
and the{' '} and the{' '}
<Link <Link
to="https://headscale.net/stable/ref/acls/"
name="Headscale ACL documentation" name="Headscale ACL documentation"
to="https://headscale.net/stable/ref/acls/"
> >
Headscale docs Headscale docs
</Link> </Link>
@@ -91,14 +80,14 @@ export default function Page() {
</p> </p>
{fetcher.data?.error !== undefined ? ( {fetcher.data?.error !== undefined ? (
<Notice <Notice
variant="error"
title={fetcher.data.error.split(':')[0] ?? 'Error'} title={fetcher.data.error.split(':')[0] ?? 'Error'}
variant="error"
> >
{fetcher.data.error.split(':').slice(1).join(': ') ?? {fetcher.data.error.split(':').slice(1).join(': ') ??
'An unknown error occurred while trying to update the ACL policy.'} 'An unknown error occurred while trying to update the ACL policy.'}
</Notice> </Notice>
) : undefined} ) : undefined}
<Tabs label="ACL Editor" className="mb-4"> <Tabs className="mb-4" label="ACL Editor">
<Tabs.Item <Tabs.Item
key="edit" key="edit"
title={ title={
@@ -110,8 +99,8 @@ export default function Page() {
> >
<Editor <Editor
isDisabled={disabled} isDisabled={disabled}
value={codePolicy}
onChange={setCodePolicy} onChange={setCodePolicy}
value={codePolicy}
/> />
</Tabs.Item> </Tabs.Item>
<Tabs.Item <Tabs.Item
@@ -145,7 +134,6 @@ export default function Page() {
</Tabs.Item> </Tabs.Item>
</Tabs> </Tabs>
<Button <Button
variant="heavy"
className="mr-2" className="mr-2"
isDisabled={ isDisabled={
disabled || disabled ||
@@ -158,6 +146,7 @@ export default function Page() {
formData.append('policy', codePolicy); formData.append('policy', codePolicy);
fetcher.submit(formData, { method: 'PATCH' }); fetcher.submit(formData, { method: 'PATCH' });
}} }}
variant="heavy"
> >
Save Save
</Button> </Button>
+6 -12
View File
@@ -1,13 +1,9 @@
import { ActionFunctionArgs, data, redirect } from 'react-router'; import { data, redirect } from 'react-router';
import { LoadContext } from '~/server'; import ResponseError from '~/server/headscale/api/response-error';
import ResponseError from '~/server/headscale/api-error';
import { Key } from '~/types';
import log from '~/utils/log'; import log from '~/utils/log';
import type { Route } from './+types/page';
export async function loginAction({ export async function loginAction({ request, context }: Route.LoaderArgs) {
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const formData = await request.formData(); const formData = await request.formData();
const apiKey = formData.has('api_key') const apiKey = formData.has('api_key')
? String(formData.get('api_key')) ? String(formData.get('api_key'))
@@ -31,11 +27,9 @@ export async function loginAction({
throw data('Received an empty `api_key`', { status: 400 }); throw data('Received an empty `api_key`', { status: 400 });
} }
const api = context.hsApi.getRuntimeClient(apiKey);
try { try {
const { apiKeys } = await context.client.get<{ apiKeys: Key[] }>( const apiKeys = await api.getApiKeys();
'v1/apikey',
apiKey,
);
// We don't need to check for 0 API keys because this request cannot // We don't need to check for 0 API keys because this request cannot
// be authenticated correctly without an API key // 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#troubleshooting"
>
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 try 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>
);
}
}
+103 -70
View File
@@ -1,78 +1,78 @@
import { useEffect } from 'react'; import { AlertCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
import { import {
ActionFunctionArgs,
data,
Form, Form,
LoaderFunctionArgs,
Link as RemixLink, Link as RemixLink,
redirect, redirect,
useActionData,
useLoaderData,
useSearchParams, useSearchParams,
} from 'react-router'; } from 'react-router';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Card from '~/components/Card'; import Card from '~/components/Card';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Input from '~/components/Input'; import Input from '~/components/Input';
import type { LoadContext } from '~/server'; import Link from '~/components/Link';
import { useLiveData } from '~/utils/live-data'; import { useLiveData } from '~/utils/live-data';
import type { Route } from './+types/page';
import { loginAction } from './action'; import { loginAction } from './action';
import { OidcConfigErrorNotice } from './config-error';
import Logout from './logout'; import Logout from './logout';
import { OidcErrorNotice } from './oidc-error';
export async function loader({ export async function loader({ request, context }: Route.LoaderArgs) {
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try { try {
await context.sessions.auth(request); await context.sessions.auth(request);
return redirect('/machines'); return redirect('/machines');
} catch {} } catch {}
const qp = new URL(request.url).searchParams; const qp = new URL(request.url).searchParams;
const state = qp.get('s') ?? undefined; const urlState = 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,
},
);
}
// 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'); return redirect('/oidc/start');
} }
const isOidcConnectorEnabled = context.oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled
? (context.oidcConnector?.errors ?? [])
: [];
return { return {
oidc: context.oidc, isCookieSecureEnabled: context.config.server.cookie_secure,
state, isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
}; };
} }
export async function action(request: ActionFunctionArgs<LoadContext>) { export const action = loginAction;
return loginAction(request);
}
export default function Page() { export default function Page({ loaderData, actionData }: Route.ComponentProps) {
const { state, oidc } = useLoaderData<typeof loader>(); const {
const formData = useActionData<typeof action>(); isCookieSecureEnabled,
isOidcConnectorEnabled,
oidcErrorCodes,
urlState,
} = loaderData;
const [showCookieWarning, setShowCookieWarning] = useState(false);
const [params] = useSearchParams(); const [params] = useSearchParams();
const { pause } = useLiveData(); const { pause } = useLiveData();
useEffect(() => { useEffect(() => {
// This page does NOT need stale while revalidate logic // This page does NOT need stale while revalidate logic
pause(); pause();
if (isCookieSecureEnabled && window.location.protocol !== 'https:') {
setShowCookieWarning(true);
}
}); });
useEffect(() => { useEffect(() => {
// State is a one time thing, we need to remove it after it has // State is a one time thing, we need to remove it after it has
// been consumed to prevent logic loops. // been consumed to prevent logic loops.
if (state !== null) { if (urlState !== null) {
const searchParams = new URLSearchParams(params); const searchParams = new URLSearchParams(params);
searchParams.delete('s'); searchParams.delete('s');
@@ -85,48 +85,81 @@ export default function Page() {
window.history.replaceState(null, '', newUrl); window.history.replaceState(null, '', newUrl);
} }
}, [state, params]); }, [urlState, params]);
if (state === 'logout') { if (urlState === 'logout') {
return <Logout />; return <Logout />;
} }
return ( return (
<div className="flex w-screen h-screen items-center justify-center"> <div className="flex w-screen h-screen items-center justify-center">
<Card className="max-w-md m-4 sm:m-0"> <div>
<Card.Title>Welcome to Headplane</Card.Title> {urlState?.startsWith('error_') ? (
<Form method="POST"> <OidcErrorNotice code={urlState} />
<Card.Text> ) : oidcErrorCodes.length > 0 ? (
Enter an API key to authenticate with Headplane. You can generate <OidcConfigErrorNotice errors={oidcErrorCodes} />
one by running <Code>headscale apikeys create</Code> in your ) : showCookieWarning ? (
terminal. <Card className="max-w-md m-4 sm:m-0 mb-4 sm:mb-4 border border-red-500">
</Card.Text> <div className="flex items-center justify-between gap-4">
<Input <Card.Title className="text-red-500">
className="mt-8 mb-2" Configuration Issue
isRequired </Card.Title>
label="API Key" <AlertCircle className="w-6 h-6 mb-2 text-red-500" />
labelHidden </div>
name="api_key" {showCookieWarning ? (
placeholder="API Key" <Card.Text className="text-sm">
type="password" Headplane is configured to use secure cookies, but this site is
/> being served over an insecure connection and login will not work
{formData?.success === false ? ( correctly.{' '}
<Card.Text className="text-sm mb-2 text-red-600 dark:text-red-300"> <Link
{formData.message} name="Headplane Common Issues"
</Card.Text> to="https://headplane.net/configuration/common-issues#issue-logging-in-does-not-do-anything"
) : undefined} >
<Button className="w-full" type="submit" variant="heavy"> Learn more.
Sign In </Link>
</Button> </Card.Text>
</Form> ) : undefined}
{oidc ? ( </Card>
<RemixLink to="/oidc/start">
<Button className="w-full mt-2" variant="light">
Single Sign-On
</Button>
</RemixLink>
) : undefined} ) : 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> </div>
); );
} }
+98 -89
View File
@@ -1,75 +1,90 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { count, eq } from 'drizzle-orm'; import { count, eq } from 'drizzle-orm';
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router'; import * as oidc from 'openid-client';
import { data, redirect } from 'react-router';
import { ulid } from 'ulidx'; import { ulid } from 'ulidx';
import type { LoadContext } from '~/server';
import { HeadplaneConfig } from '~/server/config/schema';
import { users } from '~/server/db/schema'; import { users } from '~/server/db/schema';
import { Roles } from '~/server/web/roles'; import { Roles } from '~/server/web/roles';
import { FlowUser, finishAuthFlow, formatError } from '~/utils/oidc'; import log from '~/utils/log';
import { send } from '~/utils/res'; import { createOidcStateCookie } from '~/utils/oidc-state';
import type { Route } from './+types/oidc-callback';
interface OidcFlowSession { export async function loader({ request, context }: Route.LoaderArgs) {
state: string; if (!context.oidcConnector?.isValid) {
nonce: string; throw data('OIDC is not enabled or misconfigured', { status: 501 });
code_verifier: string;
redirect_uri: string;
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
if (!context.oidc) {
throw new Error('OIDC is not enabled');
} }
// Check if we have 0 query parameters
const url = new URL(request.url); const url = new URL(request.url);
if (url.searchParams.toString().length === 0) { if (url.searchParams.toString().length === 0) {
return redirect('/login'); return redirect('/login?s=error_no_query');
} }
const cookie = createCookie('__oidc_auth_flow', { const cookie = createOidcStateCookie(context.config);
httpOnly: true, const oidcCookieState = await cookie.parse(request.headers.get('Cookie'));
maxAge: 300, // 5 minutes
});
const data: OidcFlowSession | null = await cookie.parse( if (oidcCookieState == null) {
request.headers.get('Cookie'), log.warn('auth', 'Called OIDC callback without session cookie');
); return redirect('/login?s=error_no_session');
if (data === null) {
console.warn('OIDC flow session not found');
return redirect('/login');
} }
const { code_verifier, state, nonce, redirect_uri } = data; const { state, nonce, redirect_uri, verifier } = oidcCookieState;
if (!code_verifier || !state || !nonce || !redirect_uri) { if (!state || !nonce || !redirect_uri || !verifier) {
return send({ error: 'Missing OIDC state' }, { status: 400 }); 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 { try {
let user = await finishAuthFlow(context.oidc, flowOptions); const callbackUrl = new URL(redirect_uri);
user = { const currentUrl = new URL(request.url);
...user, callbackUrl.search = currentUrl.search;
picture: setOidcPictureForSource(
user, const tokens = await oidc.authorizationCodeGrant(
context.config.oidc?.profile_picture_source ?? 'oidc', context.oidcConnector.client,
), callbackUrl,
}; {
expectedState: state,
expectedNonce: nonce,
...(context.oidcConnector.usePKCE
? { pkceCodeVerifier: verifier }
: {}),
},
);
const claims = tokens.claims();
if (claims?.sub == null) {
log.warn('auth', 'No subject found in OIDC claims');
return redirect('/login?s=error_no_sub');
}
const userInfo = await oidc.fetchUserInfo(
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 const [{ count: userCount }] = await context.db
.select({ count: count() }) .select({ count: count() })
@@ -80,52 +95,46 @@ export async function loader({
.insert(users) .insert(users)
.values({ .values({
id: ulid(), id: ulid(),
sub: user.subject, sub: claims.sub,
caps: userCount === 0 ? Roles.owner : Roles.member, caps: userCount === 0 ? Roles.owner : Roles.member,
}) })
.onConflictDoNothing(); .onConflictDoNothing();
return redirect('/machines', { return redirect('/', {
headers: { headers: {
'Set-Cookie': await context.sessions.createSession({ 'Set-Cookie': await context.sessions.createSession({
// TODO: This is breaking, to stop the "over-generation" of API api_key: context.oidcConnector.apiKey,
// keys because they are currently non-deletable in the headscale user: {
// database. Look at this in the future once we have a solution subject: claims.sub,
// or we have permissioned API keys. username,
api_key: context.config.oidc?.headscale_api_key!, name,
user, email: userInfo.email,
picture,
},
}), }),
}, },
}); });
} catch (error) { } catch (error) {
return new Response(JSON.stringify(formatError(error)), { if (error instanceof oidc.ResponseBodyError) {
status: 500, log.error(
headers: { 'auth',
'Content-Type': 'application/json', 'Got an OIDC response error body: %s',
}, JSON.stringify(error.cause),
}); );
}
}
type PictureSource = NonNullable<
HeadplaneConfig['oidc']
>['profile_picture_source'];
function setOidcPictureForSource(user: FlowUser, source: PictureSource) {
// Already set by default in the callback, so we can just return it
if (source === 'oidc') {
return user.picture;
}
if (source === 'gravatar') {
if (!user.email) {
return undefined;
} }
const emailHash = user.email.trim().toLowerCase(); if (error instanceof oidc.AuthorizationResponseError) {
const hash = createHash('sha256').update(emailHash).digest('hex'); log.error(
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`; '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');
}
} }
+68 -27
View File
@@ -1,43 +1,84 @@
import { createCookie, type LoaderFunctionArgs, redirect } from 'react-router'; import * as oidc from 'openid-client';
import type { LoadContext } from '~/server'; import { data, redirect } from 'react-router';
import { beginAuthFlow, getRedirectUri } from '~/utils/oidc'; import { HeadplaneConfig } from '~/server/config/config-schema';
import { createOidcStateCookie } from '~/utils/oidc-state';
import type { Route } from './+types/oidc-start';
export async function loader({ export async function loader({ request, context }: Route.LoaderArgs) {
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try { try {
await context.sessions.auth(request); await context.sessions.auth(request);
return redirect('/machines'); return redirect('/');
} catch {} } catch {}
if (!context.oidc || !context.config.oidc) { if (!context.oidcConnector?.isValid) {
throw new Error('OIDC is not enabled'); throw data('OIDC is not enabled or misconfigured', { status: 501 });
} }
const cookie = createCookie('__oidc_auth_flow', { const cookie = createOidcStateCookie(context.config);
httpOnly: true, const redirect_uri = getRedirectUri(context.config, request);
maxAge: 300, // 5 minutes
const nonce = oidc.randomNonce();
const verifier = oidc.randomPKCECodeVerifier();
const state = oidc.randomState();
const url = oidc.buildAuthorizationUrl(context.oidcConnector.client, {
...(context.oidcConnector.extraParams ?? {}),
scope: context.oidcConnector.scope,
redirect_uri,
state,
nonce,
...(context.oidcConnector.usePKCE
? {
code_challenge_method: 'S256',
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
}
: {}),
}); });
const redirectUri = return redirect(url.href, {
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, {
status: 302, status: 302,
headers: { headers: {
'Set-Cookie': await cookie.serialize({ 'Set-Cookie': await cookie.serialize({
state: data.state, state,
nonce: data.nonce, nonce,
code_verifier: data.codeVerifier, verifier,
redirect_uri: redirectUri, redirect_uri,
}), }),
}, },
}); });
} }
function getRedirectUri(config: HeadplaneConfig, req: Request): string {
if (config.server.base_url != null) {
const url = new URL(`${__PREFIX__}/oidc/callback`, config.server.base_url);
return url.href;
}
if (config.oidc?.redirect_uri != null) {
const url = new URL(
`${__PREFIX__}/oidc/callback`,
config.oidc.redirect_uri,
);
return url.href;
}
const url = new URL(`${__PREFIX__}/oidc/callback`, req.url);
let host = req.headers.get('Host');
if (!host) {
host = req.headers.get('X-Forwarded-Host');
}
if (!host) {
throw data(
'Cannot determine redirect URI: no Host or X-Forwarded-Host header',
{
status: 500,
},
);
}
const proto = req.headers.get('X-Forwarded-Proto');
url.protocol = proto ?? 'http:';
url.host = host;
return url.href;
}
+205 -225
View File
@@ -1,11 +1,8 @@
import { ActionFunctionArgs, data } from 'react-router'; import { data } from 'react-router';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
export async function dnsAction({ export async function dnsAction({ request, context }: Route.ActionArgs) {
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const check = await context.sessions.check( const check = await context.sessions.check(
request, request,
Capabilities.write_network, Capabilities.write_network,
@@ -19,6 +16,9 @@ export async function dnsAction({
return data({ success: false }, 403); 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 formData = await request.formData();
const action = formData.get('action_id')?.toString(); const action = formData.get('action_id')?.toString();
if (!action) { if (!action) {
@@ -26,226 +26,206 @@ export async function dnsAction({
} }
switch (action) { switch (action) {
case 'rename_tailnet': case 'rename_tailnet': {
return renameTailnet(formData, context); const newName = formData.get('new_name')?.toString();
case 'toggle_magic': if (!newName) {
return toggleMagic(formData, context); return data({ success: false }, 400);
case 'remove_ns': }
return removeNs(formData, context);
case 'add_ns': await context.hs.patch([
return addNs(formData, context); {
case 'remove_domain': path: 'dns.base_domain',
return removeDomain(formData, context); value: newName,
case 'add_domain': },
return addDomain(formData, context); ]);
case 'remove_record':
return removeRecord(formData, context); await context.integration?.onConfigChange(api);
case 'add_record': return { message: 'Tailnet renamed successfully' };
return addRecord(formData, context); }
case 'override_dns': case 'toggle_magic': {
return overrideDns(formData, context); 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: default:
return data({ success: false }, 400); 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; isAgent?: boolean;
magic?: string; magic?: string;
isDisabled?: boolean; isDisabled?: boolean;
existingTags?: string[];
} }
export default function MachineRow({ export default function MachineRow({
@@ -30,6 +31,7 @@ export default function MachineRow({
isAgent, isAgent,
magic, magic,
isDisabled, isDisabled,
existingTags,
}: Props) { }: Props) {
const uiTags = useMemo(() => { const uiTags = useMemo(() => {
const tags = uiTagsForNode(node, isAgent); const tags = uiTagsForNode(node, isAgent);
@@ -64,7 +66,10 @@ export default function MachineRow({
{node.givenName} {node.givenName}
</p> </p>
<p className="text-sm opacity-50"> <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> </p>
<div className="flex gap-1 flex-wrap mt-1.5"> <div className="flex gap-1 flex-wrap mt-1.5">
{mapTagsToComponents(node, uiTags)} {mapTagsToComponents(node, uiTags)}
@@ -143,6 +148,7 @@ export default function MachineRow({
</td> </td>
<td className="py-2 pr-0.5"> <td className="py-2 pr-0.5">
<MenuOptions <MenuOptions
existingTags={existingTags}
isDisabled={isDisabled} isDisabled={isDisabled}
magic={magic} magic={magic}
node={node} node={node}
+25 -21
View File
@@ -11,12 +11,14 @@ import Move from '../dialogs/move';
import Rename from '../dialogs/rename'; import Rename from '../dialogs/rename';
import Routes from '../dialogs/routes'; import Routes from '../dialogs/routes';
import Tags from '../dialogs/tags'; import Tags from '../dialogs/tags';
interface MenuProps { interface MenuProps {
node: PopulatedNode; node: PopulatedNode;
users: User[]; users: User[];
magic?: string; magic?: string;
isFullButton?: boolean; isFullButton?: boolean;
isDisabled?: boolean; isDisabled?: boolean;
existingTags?: string[];
} }
type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null; type Modal = 'rename' | 'expire' | 'remove' | 'routes' | 'move' | 'tags' | null;
@@ -27,6 +29,7 @@ export default function MachineMenu({
users, users,
isFullButton, isFullButton,
isDisabled, isDisabled,
existingTags,
}: MenuProps) { }: MenuProps) {
const [modal, setModal] = useState<Modal>(null); const [modal, setModal] = useState<Modal>(null);
const supportsTailscaleSSH = const supportsTailscaleSSH =
@@ -36,8 +39,8 @@ export default function MachineMenu({
<div className="flex items-center justify-end px-4 gap-1.5"> <div className="flex items-center justify-end px-4 gap-1.5">
{modal === 'remove' && ( {modal === 'remove' && (
<Delete <Delete
machine={node}
isOpen={modal === 'remove'} isOpen={modal === 'remove'}
machine={node}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
if (!isOpen) setModal(null); if (!isOpen) setModal(null);
}} }}
@@ -45,19 +48,19 @@ export default function MachineMenu({
)} )}
{modal === 'move' && ( {modal === 'move' && (
<Move <Move
machine={node}
users={users}
isOpen={modal === 'move'} isOpen={modal === 'move'}
machine={node}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
if (!isOpen) setModal(null); if (!isOpen) setModal(null);
}} }}
users={users}
/> />
)} )}
{modal === 'rename' && ( {modal === 'rename' && (
<Rename <Rename
isOpen={modal === 'rename'}
machine={node} machine={node}
magic={magic} magic={magic}
isOpen={modal === 'rename'}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
if (!isOpen) setModal(null); if (!isOpen) setModal(null);
}} }}
@@ -65,8 +68,8 @@ export default function MachineMenu({
)} )}
{modal === 'routes' && ( {modal === 'routes' && (
<Routes <Routes
node={node}
isOpen={modal === 'routes'} isOpen={modal === 'routes'}
node={node}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
if (!isOpen) setModal(null); if (!isOpen) setModal(null);
}} }}
@@ -74,8 +77,9 @@ export default function MachineMenu({
)} )}
{modal === 'tags' && ( {modal === 'tags' && (
<Tags <Tags
machine={node} existingTags={existingTags}
isOpen={modal === 'tags'} isOpen={modal === 'tags'}
machine={node}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
if (!isOpen) setModal(null); if (!isOpen) setModal(null);
}} }}
@@ -83,8 +87,8 @@ export default function MachineMenu({
)} )}
{node.expired && modal === 'expire' ? undefined : ( {node.expired && modal === 'expire' ? undefined : (
<Expire <Expire
machine={node}
isOpen={modal === 'expire'} isOpen={modal === 'expire'}
machine={node}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
if (!isOpen) setModal(null); if (!isOpen) setModal(null);
}} }}
@@ -95,33 +99,23 @@ export default function MachineMenu({
isFullButton ? ( isFullButton ? (
<Button <Button
className="flex items-center gap-x-2" className="flex items-center gap-x-2"
variant="heavy"
onPress={() => { onPress={() => {
// We need to use JS to open the SSH URL // We need to use JS to open the SSH URL
// in a new WINDOW since href can only // in a new WINDOW since href can only
// do a new TAB. // do a new TAB.
window.open( window.open(
`${__PREFIX__}/ssh?hostname=${node.name}`, `${__PREFIX__}/ssh?hostname=${node.givenName}`,
'_blank', '_blank',
'noopener,noreferrer,width=800,height=600', 'noopener,noreferrer,width=800,height=600',
); );
}} }}
variant="heavy"
> >
<SquareTerminal className="h-5" /> <SquareTerminal className="h-5" />
<p>SSH</p> <p>SSH</p>
</Button> </Button>
) : ( ) : (
<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( className={cn(
'py-0.5 w-fit bg-transparent border-transparent', 'py-0.5 w-fit bg-transparent border-transparent',
'border group-hover:border-headplane-200', 'border group-hover:border-headplane-200',
@@ -129,6 +123,16 @@ export default function MachineMenu({
'opacity-0 pointer-events-none group-hover:opacity-100', 'opacity-0 pointer-events-none group-hover:opacity-100',
'group-hover:pointer-events-auto', '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 SSH
</Button> </Button>
@@ -142,19 +146,19 @@ export default function MachineMenu({
</Menu.Button> </Menu.Button>
) : ( ) : (
<Menu.IconButton <Menu.IconButton
label="Machine Options"
className={cn( className={cn(
'py-0.5 w-10 bg-transparent border-transparent', 'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200', 'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700', 'dark:group-hover:border-headplane-700',
)} )}
label="Machine Options"
> >
<Ellipsis className="h-5" /> <Ellipsis className="h-5" />
</Menu.IconButton> </Menu.IconButton>
)} )}
<Menu.Panel <Menu.Panel
onAction={(key) => setModal(key as Modal)}
disabledKeys={node.expired ? ['expire'] : []} disabledKeys={node.expired ? ['expire'] : []}
onAction={(key) => setModal(key as Modal)}
> >
<Menu.Section> <Menu.Section>
<Menu.Item key="rename">Edit machine name</Menu.Item> <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 ( return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}> <Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel <Dialog.Panel
variant="destructive"
onSubmit={() => navigate('/machines')} onSubmit={() => navigate('/machines')}
variant="destructive"
> >
<Dialog.Title>Remove {machine.givenName}</Dialog.Title> <Dialog.Title>Remove {machine.givenName}</Dialog.Title>
<Dialog.Text> <Dialog.Text>
This machine will be permanently removed from your network. To re-add This machine will be permanently removed from your network. To re-add
it, you will need to reauthenticate to your tailnet from the device. it, you will need to reauthenticate to your tailnet from the device.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="action_id" value="delete" /> <input name="action_id" type="hidden" value="delete" />
<input type="hidden" name="node_id" value={machine.id} /> <input name="node_id" type="hidden" value={machine.id} />
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
); );
+8 -6
View File
@@ -20,21 +20,23 @@ export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
<Dialog.Text> <Dialog.Text>
The owner of the machine is the user associated with it. The owner of the machine is the user associated with it.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="action_id" value="reassign" /> <input name="action_id" type="hidden" value="reassign" />
<input type="hidden" name="node_id" value={machine.id} /> <input name="node_id" type="hidden" value={machine.id} />
<input type="hidden" name="user_id" value={userId?.toString()} /> <input name="user_id" type="hidden" value={userId?.toString()} />
<Select <Select
defaultSelectedKey={machine.user.id}
isRequired isRequired
label="Owner" label="Owner"
name="user" name="user"
placeholder="Select a user"
defaultSelectedKey={machine.user.id}
onSelectionChange={(key) => { onSelectionChange={(key) => {
setUserId(key); setUserId(key);
}} }}
placeholder="Select a user"
> >
{users.map((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> </Select>
</Dialog.Panel> </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 <Code isCopyable>tailscale up --login-server={data.server}</Code> on
your device. your device.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="action_id" value="register" /> <input name="action_id" type="hidden" value="register" />
<Input <Input
isRequired isRequired
label="Machine Key" label="Machine Key"
placeholder="AbCd..."
validationBehavior="native"
name="register_key" name="register_key"
onChange={setMkey} onChange={setMkey}
placeholder="AbCd..."
validationBehavior="native"
/> />
<Select <Select
isRequired isRequired
@@ -46,12 +46,14 @@ export default function NewMachine(data: NewMachineProps) {
placeholder="Select a user" placeholder="Select a user"
> >
{data.users.map((user) => ( {data.users.map((user) => (
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item> <Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
))} ))}
</Select> </Select>
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
<Menu isDisabled={data.isDisabled} disabledKeys={data.disabledKeys}> <Menu disabledKeys={data.disabledKeys} isDisabled={data.isDisabled}>
<Menu.Button variant="heavy">Add Device</Menu.Button> <Menu.Button variant="heavy">Add Device</Menu.Button>
<Menu.Panel <Menu.Panel
onAction={(key) => { 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 This name is shown in the admin panel, in Tailscale clients, and used
when generating MagicDNS names. when generating MagicDNS names.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="action_id" value="rename" /> <input name="action_id" type="hidden" value="rename" />
<input type="hidden" name="node_id" value={machine.id} /> <input name="node_id" type="hidden" value={machine.id} />
<Input <Input
defaultValue={machine.givenName}
isRequired isRequired
label="Machine name" label="Machine name"
placeholder="Machine name"
validationBehavior="native"
name="name" name="name"
defaultValue={machine.givenName}
onChange={setName} onChange={setName}
placeholder="Machine name"
validate={(value) => { validate={(value) => {
if (value.length === 0) { if (value.length === 0) {
return 'Cannot be empty'; return 'Cannot be empty';
@@ -66,6 +65,7 @@ export default function Rename({
return 'Cannot contain consecutive hyphens'; return 'Cannot contain consecutive hyphens';
} }
}} }}
validationBehavior="native"
/> />
{magic ? ( {magic ? (
name.length > 0 && name !== machine.givenName ? ( 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 Connect to devices you can&apos;t install Tailscale on by advertising
IP ranges as subnet routes.{' '} IP ranges as subnet routes.{' '}
<Link <Link
to="https://tailscale.com/kb/1019/subnets"
name="Tailscale Subnets Documentation" name="Tailscale Subnets Documentation"
to="https://tailscale.com/kb/1019/subnets"
> >
Learn More Learn More
</Link> </Link>
@@ -70,8 +70,8 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
<Dialog.Text> <Dialog.Text>
Allow your network to route internet traffic through this machine.{' '} Allow your network to route internet traffic through this machine.{' '}
<Link <Link
to="https://tailscale.com/kb/1103/exit-nodes"
name="Tailscale Exit-node Documentation" name="Tailscale Exit-node Documentation"
to="https://tailscale.com/kb/1103/exit-nodes"
> >
Learn More Learn More
</Link> </Link>
+51 -46
View File
@@ -1,9 +1,9 @@
import { Plus, TagsIcon, X } from 'lucide-react'; import { Plus, TagsIcon, X } from 'lucide-react';
import { useState } from 'react'; import { useMemo, useState } from 'react';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Dialog from '~/components/Dialog'; import Dialog from '~/components/Dialog';
import Input from '~/components/Input';
import Link from '~/components/Link'; import Link from '~/components/Link';
import Select from '~/components/Select';
import TableList from '~/components/TableList'; import TableList from '~/components/TableList';
import type { Machine } from '~/types'; import type { Machine } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
@@ -12,11 +12,24 @@ interface TagsProps {
machine: Machine; machine: Machine;
isOpen: boolean; isOpen: boolean;
setIsOpen: (isOpen: boolean) => void; 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 [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 ( return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}> <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 ACL tags can be used to reference machines in your ACL policies. See
the{' '} the{' '}
<Link <Link
to="https://tailscale.com/kb/1068/acl-tags"
name="Tailscale documentation" name="Tailscale documentation"
to="https://tailscale.com/kb/1068/acl-tags"
> >
Tailscale documentation Tailscale documentation
</Link>{' '} </Link>{' '}
for more information. for more information.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="action_id" value="update_tags" /> <input name="action_id" type="hidden" value="update_tags" />
<input type="hidden" name="node_id" value={machine.id} /> <input name="node_id" type="hidden" value={machine.id} />
<input type="hidden" name="tags" value={tags.join(',')} /> <input name="tags" type="hidden" value={tags.join(',')} />
<TableList className="mt-4"> <TableList className="mt-4">
{tags.length === 0 ? ( {tags.length === 0 ? (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70"> <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> </TableList.Item>
) : ( ) : (
tags.map((item) => ( tags.map((item) => (
<TableList.Item className="font-mono" key={item} id={item}> <TableList.Item className="font-mono" id={item} key={item}>
{item} {item}
<Button <Button
className="rounded-md p-0.5" className="rounded-md p-0.5"
@@ -57,44 +70,36 @@ export default function Tags({ machine, isOpen, setIsOpen }: TagsProps) {
</TableList.Item> </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> </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.Panel>
</Dialog> </Dialog>
); );
+103 -191
View File
@@ -1,12 +1,8 @@
import { type ActionFunctionArgs, data, redirect } from 'react-router'; import { data, redirect } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import { Machine } from '~/types'; import type { Route } from './+types/machine';
export async function machineAction({ export async function machineAction({ request, context }: Route.ActionArgs) {
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const check = await context.sessions.check( const check = await context.sessions.check(
request, request,
@@ -14,7 +10,7 @@ export async function machineAction({
); );
const formData = await request.formData(); 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(); const action = formData.get('action_id')?.toString();
if (!action) { 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 // 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[] }>( const node = await api.getNode(nodeId);
'v1/node',
apiKey,
);
const node = nodes.find((node) => node.id === nodeId);
if (!node) { if (!node) {
throw data(`Machine with ID ${nodeId} not found`, { throw data(`Machine with ID ${nodeId} not found`, {
status: 404, status: 404,
@@ -65,27 +71,103 @@ export async function machineAction({
switch (action) { switch (action) {
case 'rename': { 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': { case 'delete': {
return deleteMachine(apiKey, nodeId, context); await api.deleteNode(nodeId);
return redirect('/machines');
} }
case 'expire': { case 'expire': {
return expireMachine(apiKey, nodeId, context); await api.expireNode(nodeId);
return { message: 'Machine expired' };
} }
case 'update_tags': { 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': { 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': { 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: 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 { CheckCircle, CircleSlash, Info, UserCircle } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import { data, Link as RemixLink } from 'react-router';
import { Link as RemixLink, useLoaderData } from 'react-router';
import Attribute from '~/components/Attribute'; import Attribute from '~/components/Attribute';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Card from '~/components/Card'; import Card from '~/components/Card';
@@ -9,26 +8,25 @@ import Chip from '~/components/Chip';
import Link from '~/components/Link'; import Link from '~/components/Link';
import StatusCircle from '~/components/StatusCircle'; import StatusCircle from '~/components/StatusCircle';
import Tooltip from '~/components/Tooltip'; import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import { getOSInfo, getTSVersion } from '~/utils/host-info'; 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 { mapTagsToComponents, uiTagsForNode } from './components/machine-row';
import MenuOptions from './components/menu'; import MenuOptions from './components/menu';
import Routes from './dialogs/routes'; import Routes from './dialogs/routes';
import { machineAction } from './machine-actions'; import { machineAction } from './machine-actions';
export async function loader({ export async function loader({ request, params, context }: Route.LoaderArgs) {
request,
params,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
if (!params.id) { if (!params.id) {
throw new Error('No machine ID provided'); throw new Error('No machine ID provided');
} }
if (params.id.endsWith('.ico')) {
throw data(null, { status: 204 });
}
let magic: string | undefined; let magic: string | undefined;
if (context.hs.readable()) { if (context.hs.readable()) {
if (context.hs.c?.dns.magic_dns) { if (context.hs.c?.dns.magic_dns) {
@@ -36,37 +34,32 @@ export async function loader({
} }
} }
const [machine, { users }] = await Promise.all([ const api = context.hsApi.getRuntimeClient(session.api_key);
context.client.get<{ node: Machine }>( const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
`v1/node/${params.id}`, const node = nodes.find((node) => node.id === params.id);
session.api_key,
),
context.client.get<{ users: User[] }>('v1/user', session.api_key),
]);
const lookup = await context.agents?.lookup([machine.node.nodeKey]); const lookup = await context.agents?.lookup([node.nodeKey]);
const [node] = mapNodes([machine.node], lookup); const [enhancedNode] = mapNodes([node], lookup);
const tags = Array.from( const tags = Array.from(
new Set([...node.validTags, ...node.forcedTags]), new Set([...node.validTags, ...node.forcedTags]),
).sort(); ).sort();
return { return {
node, node: enhancedNode,
tags, tags,
users, users,
magic, magic,
agent: context.agents?.agentID(), agent: context.agents?.agentID(),
stats: lookup?.[node.nodeKey], stats: lookup?.[enhancedNode.nodeKey],
existingTags: sortNodeTags(nodes),
}; };
} }
export async function action(request: ActionFunctionArgs) { export const action = machineAction;
return machineAction(request);
}
export default function Page() { export default function Page({
const { node, tags, magic, users, agent, stats } = loaderData: { node, tags, users, magic, agent, stats, existingTags },
useLoaderData<typeof loader>(); }: Route.ComponentProps) {
const [showRouting, setShowRouting] = useState(false); const [showRouting, setShowRouting] = useState(false);
const uiTags = useMemo(() => { const uiTags = useMemo(() => {
@@ -93,7 +86,13 @@ export default function Page() {
<h1 className="text-2xl font-medium">{node.givenName}</h1> <h1 className="text-2xl font-medium">{node.givenName}</h1>
<StatusCircle className="w-4 h-4" isOnline={node.online} /> <StatusCircle className="w-4 h-4" isOnline={node.online} />
</span> </span>
<MenuOptions isFullButton magic={magic} node={node} users={users} /> <MenuOptions
existingTags={existingTags}
isFullButton
magic={magic}
node={node}
users={users}
/>
</div> </div>
<div className="flex gap-1 mb-4"> <div className="flex gap-1 mb-4">
<div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4"> <div className="border-r border-headplane-100 dark:border-headplane-800 p-2 pr-4">
+28 -36
View File
@@ -1,22 +1,16 @@
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router';
import { useLoaderData } from 'react-router';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Link from '~/components/Link'; import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip'; import Tooltip from '~/components/Tooltip';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import type { Machine, User } from '~/types';
import cn from '~/utils/cn'; 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 MachineRow from './components/machine-row';
import NewMachine from './dialogs/new'; import NewMachine from './dialogs/new';
import { machineAction } from './machine-actions'; import { machineAction } from './machine-actions';
export async function loader({ export async function loader({ request, context }: Route.LoaderArgs) {
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const user = session.user; const user = session.user;
if (!user) { if (!user) {
@@ -40,10 +34,8 @@ export async function loader({
Capabilities.write_machines, Capabilities.write_machines,
); );
const [{ nodes }, { users }] = await Promise.all([ const api = context.hsApi.getRuntimeClient(session.api_key);
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key), const [nodes, users] = await Promise.all([api.getNodes(), api.getUsers()]);
context.client.get<{ users: User[] }>('v1/user', session.api_key),
]);
let magic: string | undefined; let magic: string | undefined;
if (context.hs.readable()) { if (context.hs.readable()) {
@@ -72,13 +64,9 @@ export async function loader({
}; };
} }
export async function action(request: ActionFunctionArgs) { export const action = machineAction;
return machineAction(request);
}
export default function Page() {
const data = useLoaderData<typeof loader>();
export default function Page({ loaderData }: Route.ComponentProps) {
return ( return (
<> <>
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
@@ -95,10 +83,10 @@ export default function Page() {
</p> </p>
</div> </div>
<NewMachine <NewMachine
disabledKeys={data.preAuth ? [] : ['pre-auth']} disabledKeys={loaderData.preAuth ? [] : ['pre-auth']}
isDisabled={!data.writable} isDisabled={!loaderData.writable}
server={data.publicServer ?? data.server} server={loaderData.publicServer ?? loaderData.server}
users={data.users} users={loaderData.users}
/> />
</div> </div>
<table className="table-auto w-full rounded-lg"> <table className="table-auto w-full rounded-lg">
@@ -108,7 +96,7 @@ export default function Page() {
<th className="pb-2 w-1/4"> <th className="pb-2 w-1/4">
<div className="flex items-center gap-x-1"> <div className="flex items-center gap-x-1">
<p className="uppercase text-xs font-bold">Addresses</p> <p className="uppercase text-xs font-bold">Addresses</p>
{data.magic ? ( {loaderData.magic ? (
<Tooltip> <Tooltip>
<Info className="w-4 h-4" /> <Info className="w-4 h-4" />
<Tooltip.Body className="font-normal"> <Tooltip.Body className="font-normal">
@@ -116,7 +104,7 @@ export default function Page() {
their name and also at{' '} their name and also at{' '}
<Code> <Code>
[name]. [name].
{data.magic} {loaderData.magic}
</Code> </Code>
</Tooltip.Body> </Tooltip.Body>
</Tooltip> </Tooltip>
@@ -124,7 +112,7 @@ export default function Page() {
</div> </div>
</th> </th>
{/* We only want to show the version column if there are agents */} {/* 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> <th className="uppercase text-xs font-bold pb-2">Version</th>
) : undefined} ) : undefined}
<th className="uppercase text-xs font-bold pb-2">Last Seen</th> <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', 'border-t border-headplane-100 dark:border-headplane-800',
)} )}
> >
{data.populatedNodes.map((machine) => ( {loaderData.populatedNodes.map((node) => (
<MachineRow <MachineRow
isAgent={data.agent ? data.agent === machine.nodeKey : undefined} existingTags={sortNodeTags(loaderData.nodes)}
isDisabled={ isAgent={
data.writable loaderData.agent ? loaderData.agent === node.nodeKey : undefined
? false // If the user has write permissions, they can edit all machines
: machine.user.providerId?.split('/').pop() !== data.subject
} }
key={machine.id} isDisabled={
magic={data.magic} loaderData.writable
node={machine} ? false // If the user has write permissions, they can edit all machines
users={data.users} : node.user.providerId?.split('/').pop() !==
loaderData.subject
}
key={node.id}
magic={loaderData.magic}
node={node}
users={loaderData.users}
/> />
))} ))}
</tbody> </tbody>
+66 -89
View File
@@ -1,12 +1,8 @@
import { ActionFunctionArgs, data } from 'react-router'; import { data } from 'react-router';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import { PreAuthKey } from '~/types'; import type { Route } from './+types/overview';
export async function authKeysAction({ export async function authKeysAction({ request, context }: Route.ActionArgs) {
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const check = await context.sessions.check( const check = await context.sessions.check(
request, request,
@@ -20,7 +16,7 @@ export async function authKeysAction({
} }
const formData = await request.formData(); 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(); const action = formData.get('action_id')?.toString();
if (!action) { if (!action) {
throw data('Missing `action_id` in the form data.', { throw data('Missing `action_id` in the form data.', {
@@ -29,90 +25,71 @@ export async function authKeysAction({
} }
switch (action) { switch (action) {
case 'add_preauthkey': case 'add_preauthkey': {
return await addPreAuthKey(formData, apiKey, context); const user = formData.get('user_id')?.toString();
case 'expire_preauthkey': if (!user) {
return await expirePreAuthKey(formData, apiKey, context); 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: default:
return data('Invalid action', { return data('Invalid action', {
status: 400, 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 { Key, useState } from 'react';
import { useFetcher } from 'react-router';
import Dialog from '~/components/Dialog'; import Dialog from '~/components/Dialog';
import Link from '~/components/Link'; import Link from '~/components/Link';
import NumberInput from '~/components/NumberInput'; 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.Button className="my-4">Create pre-auth key</Dialog.Button>
<Dialog.Panel> <Dialog.Panel>
<Dialog.Title>Generate auth key</Dialog.Title> <Dialog.Title>Generate auth key</Dialog.Title>
<input type="hidden" name="action_id" value="add_preauthkey" /> <input name="action_id" type="hidden" value="add_preauthkey" />
<input type="hidden" name="user_id" value={userId?.toString()} /> <input name="user_id" type="hidden" value={userId?.toString()} />
<Select <Select
className="mb-2"
description="This is the user machines will belong to when they authenticate."
isRequired isRequired
label="User" label="User"
name="user" name="user"
placeholder="Select a user"
description="This is the user machines will belong to when they authenticate."
className="mb-2"
onSelectionChange={(value) => { onSelectionChange={(value) => {
setUserId(value); setUserId(value);
}} }}
placeholder="Select a user"
> >
{data.users.map((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> </Select>
<NumberInput <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} defaultValue={90}
description="Set this key to expire after a certain number of days."
formatOptions={{ formatOptions={{
style: 'unit', style: 'unit',
unit: 'day', unit: 'day',
unitDisplay: 'short', 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 className="flex justify-between items-center gap-2 mt-6">
<div> <div>
@@ -61,15 +62,15 @@ export default function AddAuthKey(data: AddAuthKeyProps) {
</Dialog.Text> </Dialog.Text>
</div> </div>
<Switch <Switch
defaultSelected={reusable}
label="Reusable" label="Reusable"
name="reusable" name="reusable"
defaultSelected={reusable}
onChange={() => { onChange={() => {
setReusable(!reusable); setReusable(!reusable);
}} }}
/> />
</div> </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 className="flex justify-between items-center gap-2 mt-6">
<div> <div>
<Dialog.Text className="font-semibold">Ephemeral</Dialog.Text> <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 Devices authenticated with this key will be automatically removed
once they go offline.{' '} once they go offline.{' '}
<Link <Link
to="https://tailscale.com/kb/1111/ephemeral-nodes"
name="Tailscale Ephemeral Nodes Documentation" name="Tailscale Ephemeral Nodes Documentation"
to="https://tailscale.com/kb/1111/ephemeral-nodes"
> >
Learn more Learn more
</Link> </Link>
</Dialog.Text> </Dialog.Text>
</div> </div>
<Switch <Switch
defaultSelected={ephemeral}
label="Ephemeral" label="Ephemeral"
name="ephemeral" name="ephemeral"
defaultSelected={ephemeral}
onChange={() => { onChange={() => {
setEphemeral(!ephemeral); setEphemeral(!ephemeral);
}} }}
/> />
</div> </div>
<input type="hidden" name="ephemeral" value={ephemeral.toString()} /> <input name="ephemeral" type="hidden" value={ephemeral.toString()} />
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
); );
@@ -12,9 +12,9 @@ export default function ExpireAuthKey({ authKey, user }: ExpireAuthKeyProps) {
<Dialog.Button variant="heavy">Expire Key</Dialog.Button> <Dialog.Button variant="heavy">Expire Key</Dialog.Button>
<Dialog.Panel variant="destructive"> <Dialog.Panel variant="destructive">
<Dialog.Title>Expire auth key?</Dialog.Title> <Dialog.Title>Expire auth key?</Dialog.Title>
<input type="hidden" name="action_id" value="expire_preauthkey" /> <input name="action_id" type="hidden" value="expire_preauthkey" />
<input type="hidden" name="user_id" value={user.id} /> <input name="user_id" type="hidden" value={user.id} />
<input type="hidden" name="key" value={authKey.key} /> <input name="key" type="hidden" value={authKey.key} />
<Dialog.Text> <Dialog.Text>
Expiring this authentication key will immediately prevent it from Expiring this authentication key will immediately prevent it from
being used to authenticate new devices. This action cannot be undone. 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 { FileKey2 } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import type { ActionFunctionArgs, LoaderFunctionArgs } from 'react-router'; import { Link as RemixLink } from 'react-router';
import { Link as RemixLink, useLoaderData } from 'react-router';
import Code from '~/components/Code'; import Code from '~/components/Code';
import Link from '~/components/Link'; import Link from '~/components/Link';
import Notice from '~/components/Notice'; import Notice from '~/components/Notice';
import Select from '~/components/Select'; import Select from '~/components/Select';
import TableList from '~/components/TableList'; import TableList from '~/components/TableList';
import type { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import type { PreAuthKey, User } from '~/types';
import log from '~/utils/log'; import log from '~/utils/log';
import type { Route } from './+types/overview';
import { authKeysAction } from './actions'; import { authKeysAction } from './actions';
import AuthKeyRow from './auth-key-row'; import AuthKeyRow from './auth-key-row';
import AddAuthKey from './dialogs/add-auth-key'; import AddAuthKey from './dialogs/add-auth-key';
export async function loader({ export async function loader({ request, context }: Route.LoaderArgs) {
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const { users } = await context.client.get<{ users: User[] }>( const api = context.hsApi.getRuntimeClient(session.api_key);
'v1/user',
session.api_key,
);
const users = await api.getUsers();
const preAuthKeys = await Promise.all( const preAuthKeys = await Promise.all(
users users
.filter((user) => user.name?.length > 0) // Filter out any invalid users .filter((user) => user.name?.length > 0) // Filter out any invalid users
.map(async (user) => { .map(async (user) => {
const qp = new URLSearchParams();
qp.set('user', user.id);
try { try {
const { preAuthKeys } = await context.client.get<{ const preAuthKeys = await api.getPreAuthKeys(user.id);
preAuthKeys: PreAuthKey[];
}>(`v1/preauthkey?${qp.toString()}`, session.api_key);
return { return {
success: true, success: true,
user, user,
@@ -47,7 +35,7 @@ export async function loader({
success: false, success: false,
user, user,
error, error,
preAuthKeys: [] as PreAuthKey[], preAuthKeys: [],
}; };
} }
}), }),
@@ -79,13 +67,12 @@ export async function loader({
}; };
} }
export async function action(request: ActionFunctionArgs<LoadContext>) { export const action = authKeysAction;
return authKeysAction(request);
}
type Status = 'all' | 'active' | 'expired' | 'reusable' | 'ephemeral'; type Status = 'all' | 'active' | 'expired' | 'reusable' | 'ephemeral';
export default function Page() { export default function Page({
const { keys, missing, users, url, access } = useLoaderData<typeof loader>(); loaderData: { keys, missing, users, url, access },
}: Route.ComponentProps) {
const [selectedUser, setSelectedUser] = useState('__headplane_all'); const [selectedUser, setSelectedUser] = useState('__headplane_all');
const [status, setStatus] = useState<Status>('active'); const [status, setStatus] = useState<Status>('active');
const isDisabled = const isDisabled =
@@ -198,7 +185,9 @@ export default function Page() {
{[ {[
<Select.Item key="__headplane_all">All</Select.Item>, <Select.Item key="__headplane_all">All</Select.Item>,
...keys.map(({ user }) => ( ...keys.map(({ user }) => (
<Select.Item key={user.id}>{user.name || user.displayName || user.email || user.id}</Select.Item> <Select.Item key={user.id}>
{user.name || user.displayName || user.email || user.id}
</Select.Item>
)), )),
]} ]}
</Select> </Select>
+8 -12
View File
@@ -1,22 +1,18 @@
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { import { Link as RemixLink } from 'react-router';
LoaderFunctionArgs,
Link as RemixLink,
useLoaderData,
} from 'react-router';
import Link from '~/components/Link'; 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 { return {
config: context.hs.writable(), config: context.hs.writable(),
oidc: context.oidc, isOidcEnabled: context.oidcConnector?.isValid ?? false,
}; };
} }
export default function Page() { export default function Page({
const { config, oidc } = useLoaderData<typeof loader>(); loaderData: { config, isOidcEnabled },
}: Route.ComponentProps) {
return ( return (
<div className="flex flex-col gap-8 max-w-(--breakpoint-lg)"> <div className="flex flex-col gap-8 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
@@ -47,7 +43,7 @@ export default function Page() {
<ArrowRight className="w-5 h-5 ml-2" /> <ArrowRight className="w-5 h-5 ml-2" />
</div> </div>
</RemixLink> </RemixLink>
{config && oidc ? ( {config && isOidcEnabled ? (
<> <>
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<h1 className="text-2xl font-medium mb-4"> <h1 className="text-2xl font-medium mb-4">
+142 -165
View File
@@ -1,11 +1,11 @@
import { ActionFunctionArgs, data } from 'react-router'; import { data } from 'react-router';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
export async function restrictionAction({ export async function restrictionAction({
request, request,
context, context,
}: ActionFunctionArgs<LoadContext>) { }: Route.ActionArgs) {
const check = await context.sessions.check( const check = await context.sessions.check(
request, request,
Capabilities.configure_iam, 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) { switch (action) {
case 'add_domain': { 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': { 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': { 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': { 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': { 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': { 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: { 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 Add this domain to a list of allowed email domains that can
authenticate with Headscale via OIDC. authenticate with Headscale via OIDC.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="action_id" value="add_domain" /> <input name="action_id" type="hidden" value="add_domain" />
<Input <Input
isRequired
label="Domain"
description={ description={
domain.trim().length > 0 domain.trim().length > 0
? `Matches users with <user>@${domain.trim()}` ? `Matches users with <user>@${domain.trim()}`
: 'Enter a domain to match users with their email addresses.' : 'Enter a domain to match users with their email addresses.'
} }
placeholder="example.com" isInvalid={domain.trim().length === 0 || isInvalid}
isRequired
label="Domain"
name="domain" name="domain"
onChange={setDomain} onChange={setDomain}
isInvalid={domain.trim().length === 0 || isInvalid} placeholder="example.com"
/> />
{isInvalid && ( {isInvalid && (
<p className="text-red-500 text-sm mt-2"> <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 Add this group to a list of allowed groups that can authenticate with
Headscale via OIDC. Headscale via OIDC.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="action_id" value="add_group" /> <input name="action_id" type="hidden" value="add_group" />
<Input <Input
description="The group to allow for OIDC authentication."
isInvalid={group.trim().length === 0 || isInvalid}
isRequired isRequired
label="Group" label="Group"
description="The group to allow for OIDC authentication."
placeholder="admin"
name="group" name="group"
onChange={setGroup} onChange={setGroup}
isInvalid={group.trim().length === 0 || isInvalid} placeholder="admin"
/> />
{isInvalid && ( {isInvalid && (
<p className="text-red-500 text-sm mt-2"> <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 Add this user to a list of allowed users that can authenticate with
Headscale via OIDC. Headscale via OIDC.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="action_id" value="add_user" /> <input name="action_id" type="hidden" value="add_user" />
<Input <Input
description="The user to allow for OIDC authentication."
isInvalid={user.trim().length === 0 || isInvalid}
isRequired isRequired
label="User" label="User"
description="The user to allow for OIDC authentication."
placeholder="john_doe"
name="user" name="user"
onChange={setUser} onChange={setUser}
isInvalid={user.trim().length === 0 || isInvalid} placeholder="john_doe"
/> />
{isInvalid && ( {isInvalid && (
<p className="text-red-500 text-sm mt-2"> <p className="text-red-500 text-sm mt-2">
+13 -23
View File
@@ -1,24 +1,15 @@
import { import { data, Link as RemixLink } from 'react-router';
ActionFunctionArgs,
LoaderFunctionArgs,
Link as RemixLink,
data,
useLoaderData,
} from 'react-router';
import Link from '~/components/Link'; import Link from '~/components/Link';
import Notice from '~/components/Notice'; import Notice from '~/components/Notice';
import { LoadContext } from '~/server';
import { Capabilities } from '~/server/web/roles'; import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
import { restrictionAction } from './actions'; import { restrictionAction } from './actions';
import AddDomain from './dialogs/add-domain'; import AddDomain from './dialogs/add-domain';
import AddGroup from './dialogs/add-group'; import AddGroup from './dialogs/add-group';
import AddUser from './dialogs/add-user'; import AddUser from './dialogs/add-user';
import RestrictionTable from './table'; import RestrictionTable from './table';
export async function loader({ export async function loader({ request, context }: Route.LoaderArgs) {
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const check = await context.sessions.check(request, Capabilities.read_users); const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) { if (!check) {
throw data('You do not have permission to view IAM settings.', { 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) { export const action = restrictionAction;
return restrictionAction(request);
}
export default function Page() { export default function Page({
const { access, writable, settings } = useLoaderData<typeof loader>(); loaderData: { access, writable, settings },
}: Route.ComponentProps) {
const isDisabled = writable ? !access : true; const isDisabled = writable ? !access : true;
return ( return (
<div className="flex flex-col gap-4 max-w-(--breakpoint-lg)"> <div className="flex flex-col gap-4 max-w-(--breakpoint-lg)">
<div className="flex flex-col w-2/3"> <div className="flex flex-col w-2/3">
<p className="mb-4 text-md"> <p className="mb-4 text-md">
<RemixLink to="/settings" className="font-medium"> <RemixLink className="font-medium" to="/settings">
Settings Settings
</RemixLink> </RemixLink>
<span className="mx-2">/</span> Authentication Restrictions <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 used to limit access to your Tailnet to only certain users or groups
and Headplane will also respect these settings when authenticating.{' '} and Headplane will also respect these settings when authenticating.{' '}
<Link <Link
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
name="Headscale OIDC documentation" name="Headscale OIDC documentation"
to="https://headscale.net/stable/ref/oidc/#basic-configuration"
> >
Learn More Learn More
</Link> </Link>
</p> </p>
</div> </div>
<RestrictionTable <RestrictionTable
isDisabled={isDisabled}
type="domain" type="domain"
values={settings.domains} values={settings.domains}
isDisabled={isDisabled}
> >
<AddDomain domains={settings.domains} isDisabled={isDisabled} /> <AddDomain domains={settings.domains} isDisabled={isDisabled} />
</RestrictionTable> </RestrictionTable>
<RestrictionTable <RestrictionTable
isDisabled={isDisabled}
type="group" type="group"
values={settings.groups} values={settings.groups}
isDisabled={isDisabled}
> >
<AddGroup groups={settings.groups} isDisabled={isDisabled} /> <AddGroup groups={settings.groups} isDisabled={isDisabled} />
</RestrictionTable> </RestrictionTable>
<RestrictionTable <RestrictionTable
isDisabled={isDisabled}
type="user" type="user"
values={settings.users} values={settings.users}
isDisabled={isDisabled}
> >
<AddUser users={settings.users} isDisabled={isDisabled} /> <AddUser isDisabled={isDisabled} users={settings.users} />
</RestrictionTable> </RestrictionTable>
</div> </div>
); );
+4 -4
View File
@@ -40,18 +40,18 @@ export default function RestrictionTable({
)} )}
<Form method="POST"> <Form method="POST">
<input <input
type="hidden"
name="action_id" name="action_id"
type="hidden"
value={`remove_${type}`} value={`remove_${type}`}
/> />
<input type="hidden" name={type} value={value} /> <input name={type} type="hidden" value={value} />
<Button <Button
isDisabled={isDisabled}
type="submit"
className={cn( className={cn(
'px-2 py-1 rounded-md', 'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400', 'text-red-500 dark:text-red-400',
)} )}
isDisabled={isDisabled}
type="submit"
> >
Remove Remove
</Button> </Button>
+20 -43
View File
@@ -4,20 +4,11 @@ import { faker } from '@faker-js/faker';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import { data, type ShouldRevalidateFunction, useSubmit } from 'react-router';
ActionFunctionArgs,
data,
LinksFunction,
LoaderFunctionArgs,
ShouldRevalidateFunction,
useLoaderData,
useSubmit,
} from 'react-router';
import { ExternalScriptsHandle } from 'remix-utils/external-scripts'; import { ExternalScriptsHandle } from 'remix-utils/external-scripts';
import { LoadContext } from '~/server';
import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema'; import { EphemeralNodeInsert, ephemeralNodes } from '~/server/db/schema';
import { Machine, PreAuthKey, User } from '~/types';
import { useLiveData } from '~/utils/live-data'; import { useLiveData } from '~/utils/live-data';
import type { Route } from './+types/console';
import UserPrompt from './user-prompt'; import UserPrompt from './user-prompt';
import XTerm from './xterm.client'; import XTerm from './xterm.client';
@@ -25,10 +16,7 @@ export const shouldRevalidate: ShouldRevalidateFunction = () => {
return false; return false;
}; };
export async function loader({ export async function loader({ request, context }: Route.LoaderArgs) {
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const origin = new URL(request.url).origin; const origin = new URL(request.url).origin;
const assets = ['/wasm_exec.js', '/hp_ssh.wasm']; const assets = ['/wasm_exec.js', '/hp_ssh.wasm'];
const missing: string[] = []; const missing: string[] = [];
@@ -53,10 +41,9 @@ export async function loader({
if (session.user.subject === 'unknown-non-oauth') { if (session.user.subject === 'unknown-non-oauth') {
throw data('Only OAuth users are allowed to use WebSSH', 403); throw data('Only OAuth users are allowed to use WebSSH', 403);
} }
const { users } = await context.client.get<{ users: User[] }>(
'v1/user', const api = context.hsApi.getRuntimeClient(session.api_key);
session.api_key, const users = await api.getUsers();
);
// MARK: This assumes that a user has authenticated with Headscale first // MARK: This assumes that a user has authenticated with Headscale first
// Since the only way to enforce permissions via ACLs is to generate a // 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 }>( const preAuthKey = await api.createPreAuthKey(
'v1/preauthkey', lookup.id,
session.api_key, true, // ephemeral
{ false, // reusable
user: lookup.id, new Date(Date.now() + 60 * 1000), // expiration: 1 minute
reusable: false, null, // aclTags
ephemeral: true,
expiration: new Date(Date.now() + 60 * 1000).toISOString(), // 1 minute
},
); );
// TODO: Enable config to enforce generate_authkeys capability // TODO: Enable config to enforce generate_authkeys capability
@@ -130,13 +114,8 @@ export async function loader({
// ); // );
// } // }
const { nodes } = await context.client.get<{ nodes: Machine[] }>( const nodes = await api.getNodes();
'v1/node', const lookupNode = nodes.find((n) => n.givenName === hostname);
session.api_key,
);
// node.name is the hostname, given_name is the set name
const lookupNode = nodes.find((n) => n.name === hostname);
if (!lookupNode) { if (!lookupNode) {
throw data(`Node with hostname ${hostname} not found`, 404); throw data(`Node with hostname ${hostname} not found`, 404);
} }
@@ -182,11 +161,8 @@ function generateHostname(username: string) {
return `ssh-${adjective}-${noun}-${username}`; return `ssh-${adjective}-${noun}-${username}`;
} }
export async function action({ export async function action({ request, context }: Route.ActionArgs) {
request, await context.sessions.auth(request);
context,
}: ActionFunctionArgs<LoadContext>) {
const _session = await context.sessions.auth(request);
if (!context.agents?.agentID()) { if (!context.agents?.agentID()) {
throw data( throw data(
'WebSSH is only available with the Headplane agent integration', 'WebSSH is only available with the Headplane agent integration',
@@ -214,7 +190,7 @@ export async function action({
.where(eq(ephemeralNodes.auth_key, authKey)); .where(eq(ephemeralNodes.auth_key, authKey));
} }
export const links: LinksFunction = () => [ export const links: Route.LinksFunction = () => [
{ {
rel: 'preload', rel: 'preload',
href: '/hp_ssh.wasm', 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 submit = useSubmit();
const { pause } = useLiveData(); const { pause } = useLiveData();
const [ipn, setIpn] = useState<TsWasmNet | null>(null); const [ipn, setIpn] = useState<TsWasmNet | null>(null);
const [nodeKey, setNodeKey] = useState<string | null>(null); const [nodeKey, setNodeKey] = useState<string | null>(null);
const { ipnDetails, sshDetails } = useLoaderData<typeof loader>();
useEffect(() => { useEffect(() => {
if (!ipnDetails) { if (!ipnDetails) {
@@ -1,17 +1,16 @@
import { Building2, House, Key } from 'lucide-react'; import { Building2, House, Key } from 'lucide-react';
import Card from '~/components/Card'; import Card from '~/components/Card';
import Link from '~/components/Link'; import Link from '~/components/Link';
import type { HeadplaneConfig } from '~/server/config/schema';
import CreateUser from '../dialogs/create-user'; import CreateUser from '../dialogs/create-user';
interface ManageBannerProps { interface ManageBannerProps {
oidc?: NonNullable<HeadplaneConfig['oidc']>; oidc?: { issuer: string };
isDisabled?: boolean; isDisabled?: boolean;
} }
export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) { export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
return ( 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="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"> <div className="w-full p-4 border-b md:border-b-0 border-headplane-100 dark:border-headplane-800">
{oidc ? ( {oidc ? (
@@ -26,14 +25,14 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
{oidc ? ( {oidc ? (
<> <>
Users are managed through your{' '} Users are managed through your{' '}
<Link to={oidc.issuer} name="OIDC Provider"> <Link name="OIDC Provider" to={oidc.issuer}>
OpenID Connect provider OpenID Connect provider
</Link> </Link>
{'. '} {'. '}
Groups and user information do not automatically sync.{' '} Groups and user information do not automatically sync.{' '}
<Link <Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation" name="Headscale OIDC Documentation"
to="https://headscale.net/stable/ref/oidc"
> >
Learn more Learn more
</Link> </Link>
@@ -43,8 +42,8 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
Users are not managed externally. Using OpenID Connect can Users are not managed externally. Using OpenID Connect can
create a better experience when using Headscale.{' '} create a better experience when using Headscale.{' '}
<Link <Link
to="https://headscale.net/stable/ref/oidc"
name="Headscale OIDC Documentation" name="Headscale OIDC Documentation"
to="https://headscale.net/stable/ref/oidc"
> >
Learn more Learn more
</Link> </Link>
@@ -61,7 +60,7 @@ export default function ManageBanner({ oidc, isDisabled }: ManageBannerProps) {
: 'You can add, remove, and rename users here.'} : 'You can add, remove, and rename users here.'}
</p> </p>
<div className="flex items-center gap-2 mt-4"> <div className="flex items-center gap-2 mt-4">
<CreateUser isOidc={oidc !== undefined} isDisabled={isDisabled} /> <CreateUser isDisabled={isDisabled} isOidc={oidc !== undefined} />
</div> </div>
</div> </div>
</div> </div>
+4 -4
View File
@@ -22,40 +22,40 @@ export default function UserMenu({ user }: MenuProps) {
<> <>
{modal === 'rename' && ( {modal === 'rename' && (
<Rename <Rename
user={user}
isOpen={modal === 'rename'} isOpen={modal === 'rename'}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
if (!isOpen) setModal(null); if (!isOpen) setModal(null);
}} }}
user={user}
/> />
)} )}
{modal === 'delete' && ( {modal === 'delete' && (
<Delete <Delete
user={user}
isOpen={modal === 'delete'} isOpen={modal === 'delete'}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
if (!isOpen) setModal(null); if (!isOpen) setModal(null);
}} }}
user={user}
/> />
)} )}
{modal === 'reassign' && ( {modal === 'reassign' && (
<Reassign <Reassign
user={user}
isOpen={modal === 'reassign'} isOpen={modal === 'reassign'}
setIsOpen={(isOpen) => { setIsOpen={(isOpen) => {
if (!isOpen) setModal(null); if (!isOpen) setModal(null);
}} }}
user={user}
/> />
)} )}
<Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : ['reassign']}> <Menu disabledKeys={user.provider === 'oidc' ? ['rename'] : ['reassign']}>
<Menu.IconButton <Menu.IconButton
label="Machine Options"
className={cn( className={cn(
'py-0.5 w-10 bg-transparent border-transparent', 'py-0.5 w-10 bg-transparent border-transparent',
'border group-hover:border-headplane-200', 'border group-hover:border-headplane-200',
'dark:group-hover:border-headplane-700', 'dark:group-hover:border-headplane-700',
)} )}
label="Machine Options"
> >
<Ellipsis className="h-5" /> <Ellipsis className="h-5" />
</Menu.IconButton> </Menu.IconButton>
+7 -5
View File
@@ -18,22 +18,24 @@ export default function UserRow({ user, role }: UserRowProps) {
return ( return (
<tr <tr
key={user.id}
className="group hover:bg-headplane-50 dark:hover:bg-headplane-950" className="group hover:bg-headplane-50 dark:hover:bg-headplane-950"
key={user.id}
> >
<td className="pl-0.5 py-2"> <td className="pl-0.5 py-2">
<div className="flex items-center"> <div className="flex items-center">
{user.profilePicUrl ? ( {user.profilePicUrl ? (
<img <img
src={user.profilePicUrl}
alt={user.name || user.displayName} alt={user.name || user.displayName}
className="w-10 h-10 rounded-full" className="w-10 h-10 rounded-full"
src={user.profilePicUrl}
/> />
) : ( ) : (
<CircleUser className="w-10 h-10" /> <CircleUser className="w-10 h-10" />
)} )}
<div className="ml-4"> <div className="ml-4">
<p className={cn('font-semibold leading-snug')}>{user.name || user.displayName}</p> <p className={cn('font-semibold leading-snug')}>
{user.name || user.displayName}
</p>
<p className="text-sm opacity-50">{user.email}</p> <p className="text-sm opacity-50">{user.email}</p>
</div> </div>
</div> </div>
@@ -43,8 +45,8 @@ export default function UserRow({ user, role }: UserRowProps) {
</td> </td>
<td className="pl-0.5 py-2"> <td className="pl-0.5 py-2">
<p <p
suppressHydrationWarning
className="text-sm text-headplane-600 dark:text-headplane-300" className="text-sm text-headplane-600 dark:text-headplane-300"
suppressHydrationWarning
> >
{new Date(user.createdAt).toLocaleDateString()} {new Date(user.createdAt).toLocaleDateString()}
</p> </p>
@@ -56,7 +58,7 @@ export default function UserRow({ user, role }: UserRowProps) {
'text-headplane-600 dark:text-headplane-300', '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> <p suppressHydrationWarning>
{isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()} {isOnline ? 'Connected' : new Date(lastSeen).toLocaleString()}
</p> </p>
+12 -12
View File
@@ -24,15 +24,14 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
</> </>
) : undefined} ) : undefined}
</Dialog.Text> </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"> <div className="flex flex-col gap-4">
<Input <Input
isRequired isRequired
name="username"
type="text"
label="Username" label="Username"
name="username"
placeholder="my-new-user" placeholder="my-new-user"
validationBehavior="native" type="text"
validate={(value) => { validate={(value) => {
if (value.trim().length === 0) { if (value.trim().length === 0) {
return 'Username is required'; return 'Username is required';
@@ -44,19 +43,20 @@ export default function CreateUser({ isOidc, isDisabled }: CreateUserProps) {
return true; return true;
}} }}
/>
<Input
name="display_name"
type="text"
label="Display Name"
placeholder="John Doe"
validationBehavior="native" validationBehavior="native"
/> />
<Input <Input
name="email" label="Display Name"
type="email" name="display_name"
placeholder="John Doe"
type="text"
validationBehavior="native"
/>
<Input
label="Email" label="Email"
name="email"
placeholder="name@example.com" placeholder="name@example.com"
type="email"
validationBehavior="native" validationBehavior="native"
/> />
</div> </div>
+2 -2
View File
@@ -32,8 +32,8 @@ export default function DeleteUser({ user, isOpen, setIsOpen }: DeleteProps) {
)} )}
</Dialog.Text> </Dialog.Text>
)} )}
<input type="hidden" name="action_id" value="delete_user" /> <input name="action_id" type="hidden" value="delete_user" />
<input type="hidden" name="user_id" value={user.id} /> <input name="user_id" type="hidden" value={user.id} />
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
); );
+10 -8
View File
@@ -21,14 +21,16 @@ export default function ReassignUser({
<Dialog.Panel <Dialog.Panel
variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'} variant={user.headplaneRole === 'owner' ? 'unactionable' : 'normal'}
> >
<Dialog.Title>Change role for {user.name || user.displayName}?</Dialog.Title> <Dialog.Title>
Change role for {user.name || user.displayName}?
</Dialog.Title>
<Dialog.Text className="mb-6"> <Dialog.Text className="mb-6">
Most roles are carried straight from Tailscale. However, keep in mind Most roles are carried straight from Tailscale. However, keep in mind
that I have not fully implemented permissions yet and some things may that I have not fully implemented permissions yet and some things may
be accessible to everyone. The only fully completed role is Member.{' '} be accessible to everyone. The only fully completed role is Member.{' '}
<Link <Link
to="https://tailscale.com/kb/1138/user-roles"
name="Tailscale User Roles documentation" name="Tailscale User Roles documentation"
to="https://tailscale.com/kb/1138/user-roles"
> >
Learn More Learn More
</Link> </Link>
@@ -37,21 +39,21 @@ export default function ReassignUser({
<Notice>The Tailnet owner cannot be reassigned.</Notice> <Notice>The Tailnet owner cannot be reassigned.</Notice>
) : ( ) : (
<> <>
<input type="hidden" name="action_id" value="reassign_user" /> <input name="action_id" type="hidden" value="reassign_user" />
<input type="hidden" name="user_id" value={user.id} /> <input name="user_id" type="hidden" value={user.id} />
<RadioGroup <RadioGroup
isRequired
name="new_role"
label="Role"
className="gap-4" className="gap-4"
defaultValue={user.headplaneRole} defaultValue={user.headplaneRole}
isRequired
label="Role"
name="new_role"
> >
{Object.keys(Roles) {Object.keys(Roles)
.filter((role) => role !== 'owner') .filter((role) => role !== 'owner')
.map((role) => { .map((role) => {
const { name, desc } = mapRoleToName(role); const { name, desc } = mapRoleToName(role);
return ( return (
<RadioGroup.Radio key={role} value={role} label={name}> <RadioGroup.Radio key={role} label={name} value={role}>
<div className="block"> <div className="block">
<p className="font-bold">{name}</p> <p className="font-bold">{name}</p>
<p className="opacity-70">{desc}</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.Panel>
<Dialog.Title>Rename {user.name || user.displayName}?</Dialog.Title> <Dialog.Title>Rename {user.name || user.displayName}?</Dialog.Title>
<Dialog.Text className="mb-6"> <Dialog.Text className="mb-6">
Enter a new username for {user.name || user.displayName}. Changing a username will not Enter a new username for {user.name || user.displayName}. Changing a
update any ACL policies that may refer to this user by their old username will not update any ACL policies that may refer to this user
username. by their old username.
</Dialog.Text> </Dialog.Text>
<input type="hidden" name="action_id" value="rename_user" /> <input name="action_id" type="hidden" value="rename_user" />
<input type="hidden" name="user_id" value={user.id} /> <input name="user_id" type="hidden" value={user.id} />
<Input <Input
isRequired
name="new_name"
label="Username"
placeholder="my-new-name"
defaultValue={user.name} defaultValue={user.name}
isRequired
label="Username"
name="new_name"
placeholder="my-new-name"
/> />
</Dialog.Panel> </Dialog.Panel>
</Dialog> </Dialog>
+3 -6
View File
@@ -1,12 +1,9 @@
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { LoaderFunctionArgs, redirect } from 'react-router'; import { redirect } from 'react-router';
import { LoadContext } from '~/server';
import { users } from '~/server/db/schema'; import { users } from '~/server/db/schema';
import type { Route } from './+types/onboarding-skip';
export async function loader({ export async function loader({ request, context }: Route.LoaderArgs) {
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
try { try {
const { user } = await context.sessions.auth(request); const { user } = await context.sessions.auth(request);
await context.db await context.db
+8 -13
View File
@@ -1,23 +1,20 @@
import { Icon } from '@iconify/react'; import { Icon } from '@iconify/react';
import { ArrowRight } from 'lucide-react'; import { ArrowRight } from 'lucide-react';
import { useEffect } from 'react'; import { useEffect } from 'react';
import { LoaderFunctionArgs, NavLink, useLoaderData } from 'react-router'; import { NavLink } from 'react-router';
import Button from '~/components/Button'; import Button from '~/components/Button';
import Card from '~/components/Card'; import Card from '~/components/Card';
import Link from '~/components/Link'; import Link from '~/components/Link';
import Options from '~/components/Options'; import Options from '~/components/Options';
import StatusCircle from '~/components/StatusCircle'; import StatusCircle from '~/components/StatusCircle';
import { LoadContext } from '~/server';
import { Machine } from '~/types'; import { Machine } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import { useLiveData } from '~/utils/live-data'; import { useLiveData } from '~/utils/live-data';
import log from '~/utils/log'; import log from '~/utils/log';
import toast from '~/utils/toast'; import toast from '~/utils/toast';
import type { Route } from './+types/onboarding';
export async function loader({ export async function loader({ request, context }: Route.LoaderArgs) {
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
// Try to determine the OS split between Linux, Windows, macOS, iOS, and Android // Try to determine the OS split between Linux, Windows, macOS, iOS, and Android
@@ -48,13 +45,10 @@ export async function loader({
break; break;
} }
const api = context.hsApi.getRuntimeClient(session.api_key);
let firstMachine: Machine | undefined; let firstMachine: Machine | undefined;
try { try {
const { nodes } = await context.client.get<{ nodes: Machine[] }>( const nodes = await api.getNodes();
'v1/node',
session.api_key,
);
const node = nodes.find((n) => { const node = nodes.find((n) => {
if (n.user.provider !== 'oidc') { if (n.user.provider !== 'oidc') {
return false; return false;
@@ -87,8 +81,9 @@ export async function loader({
}; };
} }
export default function Page() { export default function Page({
const { user, osValue, firstMachine } = useLoaderData<typeof loader>(); loaderData: { user, osValue, firstMachine },
}: Route.ComponentProps) {
const { pause, resume } = useLiveData(); const { pause, resume } = useLiveData();
useEffect(() => { useEffect(() => {
if (firstMachine) { if (firstMachine) {
+19 -25
View File
@@ -1,10 +1,8 @@
import { useEffect, useState } from 'react'; 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 { Capabilities } from '~/server/web/roles';
import { Machine, User } from '~/types'; import type { Machine, User } from '~/types';
import cn from '~/utils/cn'; import cn from '~/utils/cn';
import type { Route } from './+types/overview';
import ManageBanner from './components/manage-banner'; import ManageBanner from './components/manage-banner';
import UserRow from './components/user-row'; import UserRow from './components/user-row';
import { userAction } from './user-actions'; import { userAction } from './user-actions';
@@ -13,10 +11,7 @@ interface UserMachine extends User {
machines: Machine[]; machines: Machine[];
} }
export async function loader({ export async function loader({ request, context }: Route.LoaderArgs) {
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.read_users); const check = await context.sessions.check(request, Capabilities.read_users);
if (!check) { if (!check) {
@@ -31,14 +26,12 @@ export async function loader({
Capabilities.write_users, Capabilities.write_users,
); );
const [machines, apiUsers] = await Promise.all([ const api = context.hsApi.getRuntimeClient(session.api_key);
context.client.get<{ nodes: Machine[] }>('v1/node', session.api_key), const [nodes, apiUsers] = await Promise.all([api.getNodes(), api.getUsers()]);
context.client.get<{ users: User[] }>('v1/user', session.api_key),
]);
const users = apiUsers.users.map((user) => ({ const users = apiUsers.map((user) => ({
...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( const roles = await Promise.all(
@@ -77,28 +70,29 @@ export async function loader({
return { return {
writable: writablePermission, // whether the user can write to the API 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, roles,
magic, magic,
users, users,
}; };
} }
export async function action(data: ActionFunctionArgs) { export const action = userAction;
return userAction(data);
}
export default function Page() { export default function Page({ loaderData }: Route.ComponentProps) {
const data = useLoaderData<typeof loader>(); const [users, setUsers] = useState<UserMachine[]>(loaderData.users);
const [users, setUsers] = useState<UserMachine[]>(data.users);
// This useEffect is entirely for the purpose of updating the users when the // 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 // 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 // the idea is to treat data.users as the source of truth and update the
// local state when it changes. // local state when it changes.
useEffect(() => { useEffect(() => {
setUsers(data.users); setUsers(loaderData.users);
}, [data.users]); }, [loaderData.users]);
return ( return (
<> <>
@@ -106,7 +100,7 @@ export default function Page() {
<p className="mb-8 text-md"> <p className="mb-8 text-md">
Manage the users in your network and their permissions. Manage the users in your network and their permissions.
</p> </p>
<ManageBanner isDisabled={!data.writable} oidc={data.oidc} /> <ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
<table className="table-auto w-full rounded-lg"> <table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300"> <thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5"> <tr className="text-left px-0.5">
@@ -127,7 +121,7 @@ export default function Page() {
.map((user) => ( .map((user) => (
<UserRow <UserRow
key={user.id} key={user.id}
role={data.roles[users.indexOf(user)]} role={loaderData.roles[users.indexOf(user)]}
user={user} user={user}
/> />
))} ))}
+99 -124
View File
@@ -1,140 +1,115 @@
import { ActionFunctionArgs, data } from 'react-router'; import { data } from 'react-router';
import type { LoadContext } from '~/server';
import { Capabilities, Roles } from '~/server/web/roles'; import { Capabilities, Roles } from '~/server/web/roles';
import { User } from '~/types'; import type { Route } from './+types/overview';
import { data400, data403 } from '~/utils/res';
export async function userAction({ export async function userAction({ request, context }: Route.ActionArgs) {
request,
context,
}: ActionFunctionArgs<LoadContext>) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const check = await context.sessions.check(request, Capabilities.write_users); const check = await context.sessions.check(request, Capabilities.write_users);
if (!check) { 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 formData = await request.formData();
const action = formData.get('action_id')?.toString(); const action = formData.get('action_id')?.toString();
if (!action) { 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) { switch (action) {
case 'create_user': case 'create_user': {
return createUser(formData, apiKey, context); const name = formData.get('username')?.toString();
case 'delete_user': const displayName = formData.get('display_name')?.toString();
return deleteUser(formData, apiKey, context); const email = formData.get('email')?.toString();
case 'rename_user':
return renameUser(formData, apiKey, context); if (!name) {
case 'reassign_user': throw data('Missing `username` in the form data.', {
return reassignUser(formData, apiKey, context); 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: 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 { Route } from './+types/healthz';
import type { LoadContext } from '~/server';
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' }), { return new Response(JSON.stringify({ status: healthy ? 'OK' : 'ERROR' }), {
status: healthy ? 200 : 500, status: healthy ? 200 : 500,
headers: { 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.
+206
View File
@@ -0,0 +1,206 @@
import { type } from 'arktype';
import log from '~/utils/log';
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',
base_url: 'string.url?',
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?',
base_url: 'string.url?',
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',
use_pkce: 'boolean = false',
redirect_uri: type('string.url')
.pipe((value, ctx) => {
log.warn(
'config',
'%s is deprecated and will be removed in 0.7.0',
ctx.propString,
);
const cleanedValue = new URL(value.trim());
if (cleanedValue.pathname.endsWith(`${__PREFIX__}/oidc/callback`)) {
cleanedValue.pathname = cleanedValue.pathname.replace(
`${__PREFIX__}/oidc/callback`,
'/',
);
log.warn(
'config',
'Please migrate to using `server.base_url` with a value of "%s"',
cleanedValue.toString(),
);
}
return cleanedValue.toString();
})
.optional(),
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?',
use_pkce: 'boolean?',
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> { export abstract class Integration<T> {
protected context: NonNullable<T>; protected context: NonNullable<T>;
@@ -11,6 +11,6 @@ export abstract class Integration<T> {
} }
abstract isAvailable(): Promise<boolean> | boolean; abstract isAvailable(): Promise<boolean> | boolean;
abstract onConfigChange(client: ApiClient): Promise<void> | void; abstract onConfigChange(client: RuntimeApiClient): Promise<void> | void;
abstract get name(): string; 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 { setTimeout } from 'node:timers/promises';
import { type } from 'arktype';
import { Client } from 'undici'; 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 log from '~/utils/log';
import type { HeadplaneConfig } from '../schema';
import { Integration } from './abstract'; import { Integration } from './abstract';
interface DockerContainer { interface DockerContainer {
@@ -11,8 +11,25 @@ interface DockerContainer {
Names: string[]; Names: string[];
} }
type T = NonNullable<HeadplaneConfig['integration']>['docker']; const configSchema = {
export default class DockerIntegration extends Integration<T> { 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 maxAttempts = 10;
private client: Client | undefined; private client: Client | undefined;
private containerId: string | undefined; private containerId: string | undefined;
@@ -21,6 +38,10 @@ export default class DockerIntegration extends Integration<T> {
return 'Docker'; return 'Docker';
} }
static get configSchema() {
return configSchema;
}
async getContainerName(label: string, value: string): Promise<string> { async getContainerName(label: string, value: string): Promise<string> {
if (!this.client) { if (!this.client) {
throw new Error('Docker client is not initialized'); 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 // Basic configuration check, the name overrides the container_label
// selector because of legacy support. // selector because of legacy support.
const { container_name, container_label } = this.context; 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( log.error(
'config', 'config',
'Missing a Docker `container_name` or `container_label`', 'Missing a Docker `container_name` or `container_label`',
@@ -94,7 +115,7 @@ export default class DockerIntegration extends Integration<T> {
try { try {
log.info('config', 'Checking API: %s', fetchU); 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) { } catch (error) {
log.error('config', 'Failed to connect to Docker API: %s', error); log.error('config', 'Failed to connect to Docker API: %s', error);
log.debug('config', 'Connection error: %o', error); log.debug('config', 'Connection error: %o', error);
@@ -127,7 +148,7 @@ export default class DockerIntegration extends Integration<T> {
const qp = new URLSearchParams({ const qp = new URLSearchParams({
filters: JSON.stringify( filters: JSON.stringify(
container_name.length > 0 container_name != null && container_name.length > 0
? { name: [container_name] } ? { name: [container_name] }
: { label: [container_label] }, : { label: [container_label] },
), ),
@@ -140,7 +161,7 @@ export default class DockerIntegration extends Integration<T> {
); );
const res = await this.client.request({ const res = await this.client.request({
method: 'GET', method: 'GET',
path: `/v1.30/containers/json?${qp.toString()}`, path: `/v1.44/containers/json?${qp.toString()}`,
}); });
if (res.statusCode !== 200) { if (res.statusCode !== 200) {
@@ -151,7 +172,7 @@ export default class DockerIntegration extends Integration<T> {
const data = (await res.body.json()) as DockerContainer[]; const data = (await res.body.json()) as DockerContainer[];
if (data.length > 1) { if (data.length > 1) {
if (container_name.length > 0) { if (container_name != null && container_name.length > 0) {
log.error( log.error(
'config', 'config',
`Found multiple containers with name ${container_name}`, `Found multiple containers with name ${container_name}`,
@@ -167,7 +188,7 @@ export default class DockerIntegration extends Integration<T> {
} }
if (data.length === 0) { if (data.length === 0) {
if (container_name.length > 0) { if (container_name != null && container_name.length > 0) {
log.error( log.error(
'config', 'config',
`No container found with the name ${container_name}`, `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; return this.client !== undefined && this.containerId !== undefined;
} }
async onConfigChange(client: ApiClient) { async onConfigChange(client: RuntimeApiClient) {
if (!this.client) { if (!this.client) {
return; return;
} }
@@ -211,7 +232,7 @@ export default class DockerIntegration extends Integration<T> {
const response = await this.client.request({ const response = await this.client.request({
method: 'POST', method: 'POST',
path: `/v1.30/containers/${this.containerId}/restart`, path: `/v1.44/containers/${this.containerId}/restart`,
}); });
if (response.statusCode !== 204) { if (response.statusCode !== 204) {
@@ -233,14 +254,14 @@ export default class DockerIntegration extends Integration<T> {
while (attempts <= this.maxAttempts) { while (attempts <= this.maxAttempts) {
try { try {
log.debug('config', 'Checking Headscale status (attempt %d)', attempts); log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck(); const status = await client.isHealthy();
if (status === false) { if (status === false) {
throw new Error('Headscale is not running'); throw new Error('Headscale is not running');
} }
log.info('config', 'Headscale is up and running'); log.info('config', 'Headscale is up and running');
return; return;
} catch (error) { } catch {
if (attempts < this.maxAttempts) { if (attempts < this.maxAttempts) {
attempts++; attempts++;
await setTimeout(1000); await setTimeout(1000);
+4 -4
View File
@@ -1,5 +1,5 @@
import { HeadplaneConfig } from '~/server/config/schema';
import log from '~/utils/log'; import log from '~/utils/log';
import type { HeadplaneConfig } from '../config-schema';
import dockerIntegration from './docker'; import dockerIntegration from './docker';
import kubernetesIntegration from './kubernetes'; import kubernetesIntegration from './kubernetes';
import procIntegration from './proc'; import procIntegration from './proc';
@@ -47,16 +47,16 @@ function getIntegration(integration: HeadplaneConfig['integration']) {
if (docker?.enabled) { if (docker?.enabled) {
log.info('config', 'Using Docker integration'); log.info('config', 'Using Docker integration');
return new dockerIntegration(integration?.docker); return new dockerIntegration(integration!.docker!);
} }
if (k8s?.enabled) { if (k8s?.enabled) {
log.info('config', 'Using Kubernetes integration'); log.info('config', 'Using Kubernetes integration');
return new kubernetesIntegration(integration?.kubernetes); return new kubernetesIntegration(integration!.kubernetes!);
} }
if (proc?.enabled) { if (proc?.enabled) {
log.info('config', 'Using Proc integration'); 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 { platform } from 'node:os';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { kill } from 'node:process'; import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import { CoreV1Api, KubeConfig } from '@kubernetes/client-node'; 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 log from '~/utils/log';
import { HeadplaneConfig } from '../schema';
import { Integration } from './abstract'; import { Integration } from './abstract';
// https://github.com/kubernetes-client/javascript/blob/055b83c6504dfd1b2a2d081efd974163c6cbb808/src/config.ts#L40 // 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 svcTokenPath = `${svcRoot}/token`;
const svcNamespacePath = `${svcRoot}/namespace`; const svcNamespacePath = `${svcRoot}/namespace`;
type T = NonNullable<HeadplaneConfig['integration']>['kubernetes']; const configSchema = {
export default class KubernetesIntegration extends Integration<T> { 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 pid: number | undefined;
private maxAttempts = 10; private maxAttempts = 10;
@@ -24,6 +39,10 @@ export default class KubernetesIntegration extends Integration<T> {
return 'Kubernetes (k8s)'; return 'Kubernetes (k8s)';
} }
static get configSchema() {
return configSchema;
}
async isAvailable() { async isAvailable() {
if (platform() !== 'linux') { if (platform() !== 'linux') {
log.error('config', 'Kubernetes is only available on 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) { if (!this.pid) {
return; return;
} }
@@ -220,14 +239,14 @@ export default class KubernetesIntegration extends Integration<T> {
while (attempts <= this.maxAttempts) { while (attempts <= this.maxAttempts) {
try { try {
log.debug('config', 'Checking Headscale status (attempt %d)', attempts); log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck(); const status = await client.isHealthy();
if (status === false) { if (status === false) {
throw new Error('Headscale is not running'); throw new Error('Headscale is not running');
} }
log.info('config', 'Headscale is up and running'); log.info('config', 'Headscale is up and running');
return; return;
} catch (error) { } catch {
if (attempts < this.maxAttempts) { if (attempts < this.maxAttempts) {
attempts++; attempts++;
await setTimeout(1000); 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 { platform } from 'node:os';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { kill } from 'node:process'; import { kill } from 'node:process';
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import { ApiClient } from '~/server/headscale/api-client'; import { type } from 'arktype';
import type { RuntimeApiClient } from '~/server/headscale/api/endpoints';
import log from '~/utils/log'; import log from '~/utils/log';
import { HeadplaneConfig } from '../schema'; import type { HeadplaneConfig } from '../config-schema';
import { Integration } from './abstract'; import { Integration } from './abstract';
type T = NonNullable<HeadplaneConfig['integration']>['proc']; const configSchema = {
export default class ProcIntegration extends Integration<T> { full: type({
enabled: 'boolean',
}),
partial: type({
enabled: 'boolean?',
}).partial(),
};
export default class ProcIntegration extends Integration<
typeof configSchema.full.infer
> {
private pid: number | undefined; private pid: number | undefined;
private maxAttempts = 10; private maxAttempts = 10;
@@ -17,6 +29,10 @@ export default class ProcIntegration extends Integration<T> {
return 'Native Linux (/proc)'; return 'Native Linux (/proc)';
} }
static get configSchema() {
return configSchema;
}
async isAvailable() { async isAvailable() {
if (platform() !== 'linux') { if (platform() !== 'linux') {
log.error('config', '/proc is only available on Linux'); log.error('config', '/proc is only available on Linux');
@@ -68,30 +84,37 @@ export default class ProcIntegration extends Integration<T> {
pids.join(', '), pids.join(', '),
); );
log.debug('config', 'Checking if any of them have Parent PID = 1, assuming thats the correct PID'); log.debug(
const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/; 'config',
for (const pid of pids) { 'Checking if any of them have Parent PID = 1, assuming thats the correct PID',
const pidStatusPath = join('/proc', pid.toString(), 'status'); );
try { const ppidRegex = /(?:PPid:\s)(\d+)(?:\n?)/;
log.debug('config', 'Reading %s', pidStatusPath); for (const pid of pids) {
const pidData = await readFile(pidStatusPath, 'utf8'); const pidStatusPath = join('/proc', pid.toString(), 'status');
const ppidResult = pidData.match(ppidRegex); try {
log.debug('config', 'Reading %s', pidStatusPath);
const pidData = await readFile(pidStatusPath, 'utf8');
const ppidResult = pidData.match(ppidRegex);
if (ppidResult !== null) { if (ppidResult !== null) {
const potentialPPid = Number.parseInt(ppidResult[1], 10); const potentialPPid = Number.parseInt(ppidResult[1], 10);
if (potentialPPid === 1) { if (potentialPPid === 1) {
this.pid = pid; this.pid = pid;
log.info('config', 'Found potential Headscale process with PID: %d based on Parent PID = 1', this.pid); log.info(
return true; 'config',
} 'Found potential Headscale process with PID: %d based on Parent PID = 1',
} this.pid,
} catch (error) { );
log.error('config', 'Failed to read %s: %s', pidStatusPath, error); return true;
} }
} }
} catch (error) {
log.error('config', 'Failed to read %s: %s', pidStatusPath, error);
}
}
return false; return false;
} }
if (pids.length === 0) { if (pids.length === 0) {
log.error('config', 'Could not find Headscale process'); log.error('config', 'Could not find Headscale process');
@@ -107,7 +130,7 @@ export default class ProcIntegration extends Integration<T> {
} }
} }
async onConfigChange(client: ApiClient) { async onConfigChange(client: RuntimeApiClient) {
if (!this.pid) { if (!this.pid) {
return; return;
} }
@@ -125,7 +148,7 @@ export default class ProcIntegration extends Integration<T> {
while (attempts <= this.maxAttempts) { while (attempts <= this.maxAttempts) {
try { try {
log.debug('config', 'Checking Headscale status (attempt %d)', attempts); log.debug('config', 'Checking Headscale status (attempt %d)', attempts);
const status = await client.healthcheck(); const status = await client.isHealthy();
if (status === false) { if (status === false) {
log.error('config', 'Headscale is not running'); log.error('config', 'Headscale is not running');
return; return;
@@ -133,7 +156,7 @@ export default class ProcIntegration extends Integration<T> {
log.info('config', 'Headscale is up and running'); log.info('config', 'Headscale is up and running');
return; return;
} catch (error) { } catch {
if (attempts < this.maxAttempts) { if (attempts < this.maxAttempts) {
attempts++; attempts++;
await setTimeout(1000); 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 { eq, isNotNull } from 'drizzle-orm';
import { LoaderFunctionArgs } from 'react-router';
import { Machine } from '~/types';
import log from '~/utils/log'; import log from '~/utils/log';
import { LoadContext } from '..'; import type { Route } from '../../layouts/+types/dashboard';
import { ephemeralNodes } from './schema'; import { ephemeralNodes } from './schema';
export async function pruneEphemeralNodes({ export async function pruneEphemeralNodes({
context, context,
request, request,
}: LoaderFunctionArgs<LoadContext>) { }: Route.LoaderArgs) {
const session = await context.sessions.auth(request); const session = await context.sessions.auth(request);
const ephemerals = await context.db const ephemerals = await context.db
.select() .select()
@@ -20,11 +18,8 @@ export async function pruneEphemeralNodes({
return; return;
} }
const { nodes } = await context.client.get<{ nodes: Machine[] }>( const api = context.hsApi.getRuntimeClient(session.api_key);
'v1/node', const nodes = await api.getNodes();
session.api_key,
);
const toPrune = nodes.filter((node) => { const toPrune = nodes.filter((node) => {
if (node.online) { if (node.online) {
return false; return false;
@@ -42,7 +37,7 @@ export async function pruneEphemeralNodes({
const promises = toPrune.map((node) => { const promises = toPrune.map((node) => {
return async () => { return async () => {
log.debug('api', `Pruning node ${node.name}`); 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 await context.db
.delete(ephemeralNodes) .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 { export default class ResponseError extends Error {
status: number; status: number;
response: string; response: string;
requestUrl: string;
responseObject?: Record<string, unknown>; responseObject?: Record<string, unknown>;
constructor(status: number, response: string) { constructor(status: number, response: string, requestUrl: string) {
super(`Response Error (${status}): ${response}`); super(`${requestUrl}: status ${status} - ${response}`);
this.name = 'ResponseError'; this.name = 'ResponseError';
this.status = status; this.status = status;
this.response = response; this.response = response;
this.requestUrl = requestUrl;
try { try {
// Try to parse the response as JSON to get a response object // 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 { setTimeout } from 'node:timers/promises';
import log from '~/utils/log'; 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 { LibSQLDatabase } from 'drizzle-orm/libsql/driver-core';
import { HostInfo } from '~/types'; import { HostInfo } from '~/types';
import log from '~/utils/log'; import log from '~/utils/log';
import { HeadplaneConfig } from './config/schema'; import { HeadplaneConfig } from './config/config-schema';
import { hostInfo } from './db/schema'; import { hostInfo } from './db/schema';
export async function createHeadplaneAgent( export async function createHeadplaneAgent(
+44 -20
View File
@@ -1,15 +1,13 @@
import { join } from 'node:path'; 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 { createHonoServer } from 'react-router-hono-server/node';
import log from '~/utils/log'; import log from '~/utils/log';
import { configureConfig, configureLogger, envVariables } from './config/env';
import { loadIntegration } from './config/integration'; import { loadIntegration } from './config/integration';
import { loadConfig } from './config/loader'; import { loadConfig } from './config/load';
import { createDbClient } from './db/client.server'; import { createDbClient } from './db/client.server';
import { createApiClient } from './headscale/api-client'; import { createHeadscaleInterface } from './headscale/api';
import { loadHeadscaleConfig } from './headscale/config-loader'; import { loadHeadscaleConfig } from './headscale/config-loader';
import { createHeadplaneAgent } from './hp-agent'; import { createHeadplaneAgent } from './hp-agent';
import { configureOidcAuth } from './web/oidc';
import { createSessionStorage } from './web/sessions'; import { createSessionStorage } from './web/sessions';
declare global { declare global {
@@ -21,13 +19,17 @@ declare global {
// This module contains a side-effect because everything running here // This module contains a side-effect because everything running here
// exists for the lifetime of the process, making it appropriate. // exists for the lifetime of the process, making it appropriate.
log.info('server', 'Running Node.js %s', versions.node); log.info('server', 'Running Node.js %s', versions.node);
configureLogger(env[envVariables.debugLog]); let config: HeadplaneConfig;
const config = await loadConfig(
configureConfig({ try {
loadEnv: env[envVariables.envOverrides], config = await loadConfig();
path: env[envVariables.configPath], } 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 db = await createDbClient(join(config.server.data_path, 'hp_persist.db'));
const agents = await createHeadplaneAgent( const agents = await createHeadplaneAgent(
@@ -36,10 +38,25 @@ const agents = await createHeadplaneAgent(
db, 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. // We also use this file to load anything needed by the react router code.
// These are usually per-request things that we need access to, like the // These are usually per-request things that we need access to, like the
// helper that can issue and revoke cookies. // helper that can issue and revoke cookies.
export type LoadContext = typeof appLoadContext; 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 = { const appLoadContext = {
config, config,
hs: await loadHeadscaleConfig( hs: await loadHeadscaleConfig(
@@ -56,19 +73,21 @@ const appLoadContext = {
cookie: { cookie: {
name: '_hp_auth', name: '_hp_auth',
secure: config.server.cookie_secure, secure: config.server.cookie_secure,
maxAge: 60 * 60 * 24, // 24 hours maxAge: config.server.cookie_max_age,
// domain: config.server.cookie_domain, domain: config.server.cookie_domain,
}, },
}), }),
client: await createApiClient( hsApi,
config.headscale.url,
config.headscale.tls_cert_path,
),
agents, agents,
integration: await loadIntegration(config.integration), integration: await loadIntegration(config.integration),
oidc: config.oidc ? await configureOidcAuth(config.oidc) : undefined, oidcConnector: config.oidc
? await createOidcConnector(
config.server.base_url,
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
: undefined,
db, db,
}; };
@@ -86,6 +105,11 @@ export default createHonoServer({
}); });
}, },
serveStaticOptions: { 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: { clientAssets: {
// This is part of our monkey-patch for react-router-hono-server // This is part of our monkey-patch for react-router-hono-server
// To see the first part, go to the patches/ directory. // To see the first part, go to the patches/ directory.
+213
View File
@@ -0,0 +1,213 @@
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;
usePKCE: 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 baseUrl The base URL of the Headplane server.
* @param config The OIDC configuration.
* @param client The Headscale runtime API client.
* @returns An OIDC connector with validation status.
*/
export async function createOidcConnector(
baseUrl: string | undefined,
config: OidcConfig,
client: RuntimeApiClient,
): Promise<OidcConnector> {
if (baseUrl == null && config.redirect_uri == null) {
log.warn(
'config',
'OIDC is enabled but `server.base_url` is not set in the config. Starting in Headplane 0.7.0 this will be required for OIDC to function properly and will throw errors if not set, see https://headplane.net/features/sso#configuring-oidc for more information.',
);
}
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,
usePKCE: config.use_pkce,
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 (config.use_pkce === true && !client.serverMetadata().supportsPKCE()) {
log.warn(
'config',
'OIDC provider does not support PKCE, but it is enabled in the config',
);
}
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) { async function createSession(payload: JWTSession, options: AuthSessionOptions) {
const now = Math.floor(Date.now() / 1000);
const secret = createHash('sha256').update(options.secret, 'utf8').digest(); const secret = createHash('sha256').update(options.secret, 'utf8').digest();
const jwt = await new EncryptJWT({ const jwt = await new EncryptJWT({
...payload, ...payload,
}) })
.setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' }) .setProtectedHeader({ alg: 'dir', enc: 'A256GCM', typ: 'JWT' })
.setIssuedAt() .setIssuedAt()
.setExpirationTime('1d') .setExpirationTime(now + options.cookie.maxAge)
.setIssuer('urn:tale:headplane') .setIssuer('urn:tale:headplane')
.setAudience('urn:tale:headplane') .setAudience('urn:tale:headplane')
.setJti(ulid()) .setJti(ulid())
@@ -218,36 +219,28 @@ export async function createSessionStorage(options: AuthSessionOptions) {
} }
async function migrateUserDatabase(path: string, db: LibSQLDatabase) { async function migrateUserDatabase(path: string, db: LibSQLDatabase) {
log.info('config', 'Migrating old user database from %s', path);
const realPath = resolve(path); const realPath = resolve(path);
log.warn(
'config',
'oidc.user_storage_file is deprecated and will be removed in Headplane 0.7.0',
);
log.warn(
'config',
'You can ignore this warning if you do not use OIDC authentication.',
);
log.warn(
'config',
'Data will be automatically migrated to the new SQL database.',
);
log.warn(
'config',
'Refer to server.data_path to ensure this path is mounted correctly is using Docker.',
);
try { try {
const handle = await open(realPath, 'a+'); const handle = await open(realPath, 'a+');
await handle.close(); await handle.close();
} catch (error) { } 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', 'Failed to migrate old user database at %s', realPath);
log.warn( log.warn(
'config', 'config',
'This is not an error, but existing users will not be migrated', '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); log.debug('config', 'Error details: %s', error);
return; return;
} }
+19 -2
View File
@@ -14,10 +14,12 @@ export interface Logger
debugEnabled: boolean; debugEnabled: boolean;
} }
const logLevels = getLogLevels();
export default { export default {
debugEnabled: true, debugEnabled: logLevels.includes('debug'),
debug: (..._: Parameters<Logger['debug']>) => {},
...Object.fromEntries( ...Object.fromEntries(
levels.map((level) => [ logLevels.map((level) => [
level, level,
(category: Category, message: string, ...args: unknown[]) => { (category: Category, message: string, ...args: unknown[]) => {
const date = new Date().toISOString(); const date = new Date().toISOString();
@@ -29,3 +31,18 @@ export default {
]), ]),
), ),
} as Logger; } 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();
}
+49
View File
@@ -0,0 +1,49 @@
import { createCookie } from 'react-router';
import type { HeadplaneConfig } from '~/server/config/config-schema';
export interface OidcStateCookie {
nonce: string;
state: string;
verifier: string;
redirect_uri: string;
}
export function createOidcStateCookie(config: HeadplaneConfig) {
const cookie = createCookie('__oidc_state', {
httpOnly: true,
maxAge: 1800,
secure: config.server.cookie_secure,
domain: config.server.cookie_domain,
path: `${__PREFIX__}/oidc/callback`,
});
return {
...cookie,
serialize: async (value: OidcStateCookie): Promise<string> => {
return cookie.serialize(value);
},
parse: async (
cookieHeader: string | null,
): Promise<OidcStateCookie | null> => {
const parsed = await cookie.parse(cookieHeader);
if (
parsed == null ||
typeof parsed !== 'object' ||
typeof parsed.nonce !== 'string' ||
typeof parsed.state !== 'string' ||
typeof parsed.verifier !== 'string' ||
typeof parsed.redirect_uri !== 'string'
) {
return null;
}
return {
nonce: parsed.nonce,
state: parsed.state,
verifier: parsed.verifier,
redirect_uri: parsed.redirect_uri,
};
},
};
}
-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 fi
if [ $need_pnpm -eq 1 ]; then 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" echo "==> Checking for pnpm"
command -v pnpm >/dev/null 2>&1 || die "pnpm not installed" command -v pnpm >/dev/null 2>&1 || die "pnpm not installed"
pnpm --version >/dev/null 2>&1 || die "pnpm not working" 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/config:/config"
- "./test/caddy/certs:/certs" - "./test/caddy/certs:/certs"
headscale: headscale:
image: "headscale/headscale:0.26.0-debug" image: "headscale/headscale:0.27.0-debug"
container_name: "headscale" container_name: "headscale"
labels: labels:
me.tale.headplane.target: headscale me.tale.headplane.target: headscale

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