mirror of
https://github.com/tale/headplane.git
synced 2026-07-31 09:42:13 +00:00
Compare commits
46 Commits
next
...
v0.6.2-beta.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 3a3e5ca65e | |||
| 3eff763436 | |||
| 6ad0847653 | |||
| 199ef46ee1 | |||
| e09c5760af | |||
| a824ea3dcf | |||
| bf3fdd47cd | |||
| f6cdad535a | |||
| ba61656fb0 | |||
| d363ed5486 | |||
| 45de5f6833 | |||
| afc5d5451a | |||
| ef6563742c | |||
| 86184e3420 | |||
| d3d7c7cc0e | |||
| c0c4ecf631 | |||
| 5ea96db816 | |||
| cc533f1b72 | |||
| 3d9b6370bf | |||
| afd512138b | |||
| 08a251cc8c | |||
| 63d1e84ebe | |||
| 20f5342ea3 | |||
| db850942ef | |||
| c9986ba466 | |||
| d331a22951 | |||
| 6be801e9bb | |||
| 299859e279 | |||
| 5090733013 | |||
| 7d77f2cb81 | |||
| c3e07e4e10 | |||
| 1aa921aebe | |||
| 444b2325fb | |||
| c84e9ca4a8 | |||
| d68737e410 | |||
| a68aedc297 | |||
| 1c0561edb8 | |||
| e883a7cd63 | |||
| cc2112a805 | |||
| afde3399e1 | |||
| b1c93512df | |||
| ce4be73faf | |||
| 192cce23b0 | |||
| 7d7e08ee54 | |||
| 06a667b561 | |||
| 75c688fd00 |
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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" />}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -30,8 +30,8 @@ export default function Routes({ node, isOpen, setIsOpen }: RoutesProps) {
|
|||||||
Connect to devices you can't install Tailscale on by advertising
|
Connect to devices you can'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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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' };
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 });
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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.
|
|
||||||
@@ -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;
|
||||||
@@ -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',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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!);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
|
||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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,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';
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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'];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
Reference in New Issue
Block a user