Compare commits

...

34 Commits

Author SHA1 Message Date
Aarnav Tale 42ffe69486 feat: add docker health check 2026-01-13 22:54:11 -05:00
Aarnav Tale 0fb02d0d8b feat: implement better token auth endpoint method heuristic for oidc 2026-01-13 22:17:17 -05:00
Aarnav Tale d189d75d5d fix: resolve random css issues with mobile layouts 2026-01-11 15:55:14 -05:00
Aarnav Tale 8105b8f94d fix: enforce machine key length being 24 on registration 2026-01-11 15:20:00 -05:00
Aarnav Tale 5953740769 chore: update issue templates for new labels 2026-01-11 15:03:23 -05:00
Aarnav Tale 7a345d118c feat: check and warn for pkce errors if possible 2026-01-11 14:55:23 -05:00
Aarnav Tale 0965e16401 fix: apply gravatar images to the users page 2026-01-11 14:55:22 -05:00
Aarnav Tale e57821df14 Merge pull request #419 from tale/update_flake_lock_action 2026-01-11 14:41:10 -05:00
github-actions[bot] dfdf5d3ff9 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/3edc4a3' (2025-12-27)
  → 'github:nixos/nixpkgs/3146c6a' (2026-01-10)
2026-01-11 08:28:57 +00:00
Aarnav Tale 4046c6edb1 Merge pull request #409 from 0nano/0nano-doc-sso-patch 2025-12-31 15:11:44 -05:00
Aarnav Tale 7817addbc6 Merge pull request #408 from tale/update_flake_lock_action 2025-12-31 15:06:10 -05:00
github-actions[bot] e727c48406 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/f997fa0' (2025-12-11)
  → 'github:nixos/nixpkgs/3edc4a3' (2025-12-27)
2025-12-28 08:28:47 +00:00
_nano 85a0f932a7 Update OIDC callback URL in SSO documentation 2025-12-21 19:07:39 +01:00
Aarnav Tale 985d7d9dc6 chore: oops forgot changelog 2025-12-14 15:09:57 -05:00
Aarnav Tale 91254902fa Merge pull request #398 from tale/update_flake_lock_action 2025-12-14 13:27:48 -05:00
github-actions[bot] 55ffd5e841 flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/a672be6' (2025-12-05)
  → 'github:nixos/nixpkgs/f997fa0' (2025-12-11)
2025-12-14 08:27:39 +00:00
Aarnav Tale 82cb74b20b fix: handle user setting owner to itself 2025-12-14 00:42:21 -05:00
Aarnav Tale e373c4a65e chore: remove some random console.logs 2025-12-13 16:35:19 -05:00
Aarnav Tale 7b4966be02 feat: track headscale acl response changes 2025-12-13 16:34:23 -05:00
Aarnav Tale 3d07b941b4 Merge pull request #389 from tale/update_flake_lock_action 2025-12-13 14:24:23 -05:00
Aarnav Tale 862180df91 Merge pull request #384 from Murgeye/main 2025-12-13 14:23:47 -05:00
Aarnav Tale 11a7c335f7 Merge pull request #394 from Lapin0t/main 2025-12-13 14:13:03 -05:00
lapinot 3d71049afe fix: accept the full discovered oidc configuration 2025-12-12 21:26:35 +01:00
Fabian Ising 8948d440fc Improve OIDC error logging 2025-12-08 10:55:19 +01:00
github-actions[bot] d5dffbaa1d flake.lock: Update
Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/a09378c' (2025-11-29)
  → 'github:nixos/nixpkgs/a672be6' (2025-12-05)
2025-12-07 08:26:05 +00:00
Aarnav Tale d1e2773179 fix: use healthy/unhealthy for info endpoint 2025-12-06 15:03:57 -05:00
Aarnav Tale 295dd43059 feat: add info route 2025-12-06 15:02:18 -05:00
Aarnav Tale b3791385b9 Merge pull request #371 from tale/update_flake_lock_action 2025-12-06 14:44:14 -05:00
Aarnav Tale 70d535fe8d Merge pull request #377 from The-Greg-O/feat/machines-search-sort 2025-12-05 15:46:09 -05:00
Aarnav Tale 2ff5dd290f docs: enable search 2025-12-05 15:38:36 -05:00
Aarnav Tale 1463a8cc42 docs: go more indepth on sso setup 2025-12-05 15:38:16 -05:00
The-Greg-O 87485a81f3 refactor: move formatTimeDelta to shared utils
Moves the formatTimeDelta helper function to app/utils/time.ts
for reusability across the codebase, as requested in PR review.
2025-12-04 12:43:52 -08:00
The-Greg-O b938642da7 feat: add search and sortable columns to machines list
- Add search input to filter machines by name or IP address
- Add sortable column headers for Name, IP, Version, and Last Seen
- Sort IP addresses numerically by octets (not alphabetically)
- Sort versions numerically by segments (1.10.0 > 1.9.0)
- Sort Last Seen with online machines treated as most recent
- Add time delta display for offline machines (e.g., "2 days ago")
- Add machine count display showing filtered/total counts
- Add clear button (X) to search input

Closes #351
2025-12-03 02:07:29 -08:00
github-actions[bot] e868f177aa flake.lock: Update
Flake lock file updates:

• Updated input 'devshell':
    'github:numtide/devshell/07bacc9' (2025-11-07)
  → 'github:numtide/devshell/17ed8d9' (2025-11-24)
• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/1d4c883' (2025-11-15)
  → 'github:nixos/nixpkgs/a09378c' (2025-11-29)
2025-11-30 08:25:12 +00:00
42 changed files with 1086 additions and 410 deletions
+26 -26
View File
@@ -1,31 +1,31 @@
name: Bug Report
description: Report an issue with Headplane
assignees: [tale]
labels: [bug, triage]
labels: ["Bug", "Needs Triage"]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the issue and steps to reproduce it.
If applicable, include any error messages or screenshots.
- type: textarea
attributes:
label: Description
description: |
A detailed description of the issue and steps to reproduce it.
If applicable, include any error messages or screenshots.
If this is not an issue with Headplane, but an issue with your
environment, please consider opening a discussion instead.
placeholder: e.g. "When I try to upload a file, I get an error message."
validations:
required: true
- type: input
attributes:
label: Headplane Version
description: What version of Headplane are you using?
placeholder: e.g. "v0.5.5"
validations:
required: true
- type: input
attributes:
label: Headscale Version
description: What version of Headscale are you using?
placeholder: e.g. "v0.25.1"
validations:
required: true
If this is not an issue with Headplane, but an issue with your
environment, please consider opening a discussion instead.
placeholder: e.g. "When I try to upload a file, I get an error message."
validations:
required: true
- type: input
attributes:
label: Headplane Version
description: What version of Headplane are you using?
placeholder: e.g. "v0.6.2"
validations:
required: true
- type: input
attributes:
label: Headscale Version
description: What version of Headscale are you using?
placeholder: e.g. "v0.27.1"
validations:
required: true
+11 -11
View File
@@ -1,15 +1,15 @@
name: Feature Request
description: Request a new feature or enhancement for Headplane
assignees: [tale]
labels: [enhancement, triage]
labels: ["Feature", "Needs Triage"]
body:
- type: textarea
attributes:
label: Description
description: |
A detailed description of the feature you would like to see added.
Please include any relevant context, such as why this feature is
important and how it would benefit other users beyond yourself.
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
validations:
required: true
- type: textarea
attributes:
label: Description
description: |
A detailed description of the feature you would like to see added.
Please include any relevant context, such as why this feature is
important and how it would benefit other users beyond yourself.
placeholder: e.g. "I would like to see support for custom themes in Headplane so that I can personalize the interface to my liking."
validations:
required: true
+9
View File
@@ -1,4 +1,5 @@
# 0.6.2 (Next)
- Added search and sortable columns to the machines list page (closes [#351](https://github.com/tale/headplane/issues/351)).
- Added support for Headscale 0.27.0 and 0.27.1
- Bundle all `node_modules` aside from native ones to reduce bundle and container size (closes [#331](https://github.com/tale/headplane/issues/331)).
- Allow conditionally compiling the SSH WASM integration when building (closes [#337](https://github.com/tale/headplane/issues/337)).
@@ -12,10 +13,18 @@
- 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.
- `oidc.token_endpoint_auth_method` is now optional and will attempt to be auto-detected, defaulting to `client_secret_basic` if unavailable (closes [#410](https://github.com/tale/headplane/issues/410)).
- 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)).
- Correctly handle invalid ACL policy inserts on Headscale 0.27+ (closes [#383](https://github.com/tale/headplane/issues/383)).
- Prevent a machine from changing its owner to itself (closes [#373](https://github.com/tale/headplane/issues/373)).
- Added an `/admin/api/info` route that can expose sensitive information if `server.info_secret` is set in the configuration (closes [#324](https://github.com/tale/headplane/issues/324)).
- Correctly apply Gravatar profile pictures on the user page if applicable (closes [#405](https://github.com/tale/headplane/issues/405)).
- Machine key registration no longer works if the key isn't 24 characters long (closes [#415](https://github.com/tale/headplane/issues/415)).
- Fixed some mobile CSS issues across the application (closes [#401](https://github.com/tale/headplane/issues/401)).
- Added a Docker healthcheck to the container (closes [#411](https://github.com/tale/headplane/issues/411)).
---
# 0.6.1 (October 12, 2025)
+12 -2
View File
@@ -11,14 +11,16 @@ ARG TARGETOS
ARG TARGETARCH
ARG IMAGE_TAG
RUN GOOS=$TARGETOS GOARCH=$TARGETARCH CGO_ENABLED=0 IMAGE_TAG=$IMAGE_TAG \
./build.sh --wasm --agent --fake-shell \
./build.sh --wasm --agent --fake-shell --healthcheck \
--wasm-output /bin/hp_ssh.wasm \
--agent-output /bin/hp_agent \
--fake-shell-output /bin/fake-sh
--fake-shell-output /bin/fake-sh \
--healthcheck-output /bin/hp_healthcheck
RUN chmod +x /bin/hp_ssh.wasm
RUN chmod +x /bin/hp_agent
RUN chmod +x /bin/fake-sh
RUN chmod +x /bin/hp_healthcheck
# Folder needs to exist for later stages
RUN mkdir -p /var/lib/headplane/agent
@@ -49,6 +51,10 @@ COPY --from=go-base /var/lib/headplane /var/lib/headplane
COPY --from=go-base /bin/fake-sh /bin/sh
COPY --from=go-base /bin/fake-sh /bin/bash
COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/bin/hp_healthcheck"]
WORKDIR /app
CMD [ "/app/build/server/index.js" ]
@@ -61,6 +67,10 @@ COPY --from=js-base /run/node_modules /app/node_modules
COPY --from=go-base /bin/hp_agent /usr/libexec/headplane/agent
COPY --from=go-base /var/lib/headplane /var/lib/headplane
COPY --from=go-base /bin/hp_healthcheck /bin/hp_healthcheck
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD ["/bin/hp_healthcheck"]
WORKDIR /app
CMD [ "node", "/app/build/server/index.js" ]
+6 -6
View File
@@ -107,6 +107,11 @@ function Panel(props: DialogPanelProps) {
return (
<Form
{...dialogProps}
className={cn(
'outline-hidden rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
method={method ?? 'POST'}
onSubmit={(event) => {
if (onSubmit) {
onSubmit(event);
@@ -114,12 +119,7 @@ function Panel(props: DialogPanelProps) {
close?.();
}}
method={method ?? 'POST'}
ref={ref}
className={cn(
'outline-hidden rounded-3xl w-full max-w-lg',
'bg-white dark:bg-headplane-900',
)}
>
<Card className="w-full max-w-lg" variant="flat">
{children}
@@ -130,9 +130,9 @@ function Panel(props: DialogPanelProps) {
<>
<Button onPress={close}>Cancel</Button>
<Button
isDisabled={isDisabled}
type="submit"
variant={variant === 'destructive' ? 'danger' : 'heavy'}
isDisabled={isDisabled}
>
Confirm
</Button>
+17 -8
View File
@@ -8,11 +8,18 @@ export interface InputProps extends AriaTextFieldProps<HTMLInputElement> {
labelHidden?: boolean;
isRequired?: boolean;
className?: string;
isInvalid?: boolean;
errorMessage?: string;
}
// TODO: Custom isInvalid logic for custom error messages
export default function Input(props: InputProps) {
const { label, labelHidden, className } = props;
const {
label,
labelHidden,
className,
isInvalid: customIsInvalid,
errorMessage,
} = props;
const ref = useRef<HTMLInputElement | null>(null);
const id = useId(props.id);
@@ -21,7 +28,7 @@ export default function Input(props: InputProps) {
inputProps,
descriptionProps,
errorMessageProps,
isInvalid,
isInvalid: ariaIsInvalid,
validationErrors,
} = useTextField(
{
@@ -32,16 +39,18 @@ export default function Input(props: InputProps) {
ref,
);
const isInvalid = customIsInvalid ?? ariaIsInvalid;
return (
<div className="flex flex-col w-full" aria-label={label}>
<div className="flex flex-col w-full">
<label
{...labelProps}
htmlFor={id}
className={cn(
'text-xs font-medium px-3 mb-0.5',
'text-headplane-700 dark:text-headplane-100',
labelHidden && 'sr-only',
)}
htmlFor={id}
>
{label}
{props.isRequired && (
@@ -50,8 +59,6 @@ export default function Input(props: InputProps) {
</label>
<input
{...inputProps}
required={props.isRequired}
ref={ref}
className={cn(
'rounded-xl px-3 py-2',
'focus:outline-hidden focus:ring-3',
@@ -59,6 +66,8 @@ export default function Input(props: InputProps) {
'border border-headplane-100 dark:border-headplane-800',
className,
)}
ref={ref}
required={props.isRequired}
/>
{props.description && (
<div
@@ -76,7 +85,7 @@ export default function Input(props: InputProps) {
{...errorMessageProps}
className={cn('text-xs px-3 mt-1', 'text-red-500 dark:text-red-400')}
>
{validationErrors.join(' ')}
{errorMessage ?? validationErrors.join(' ')}
</div>
) : null}
</div>
+5 -5
View File
@@ -23,15 +23,15 @@ function Tabs({ label, className, ...props }: TabsProps) {
<div className={cn('flex flex-col', className)}>
<div
{...tabListProps}
ref={ref}
className={cn(
'flex items-center rounded-t-xl w-fit',
'flex items-center rounded-t-xl w-fit max-w-full overflow-x-auto',
'border-headplane-100 dark:border-headplane-800',
'border-t border-x',
)}
ref={ref}
>
{[...state.collection].map((item) => (
<Tab key={item.key} item={item} state={state} />
<Tab item={item} key={item.key} state={state} />
))}
</div>
<TabsPanel key={state.selectedItem?.key} state={state} />
@@ -52,7 +52,6 @@ function Tab({ item, state }: TabsTabProps) {
return (
<div
{...tabProps}
ref={ref}
className={cn(
'pl-2 pr-3 py-2.5',
'aria-selected:bg-headplane-100 dark:aria-selected:bg-headplane-950',
@@ -60,6 +59,7 @@ function Tab({ item, state }: TabsTabProps) {
'border-r border-headplane-100 dark:border-headplane-800',
'first:rounded-tl-xl last:rounded-tr-xl last:border-r-0',
)}
ref={ref}
>
{rendered}
</div>
@@ -76,11 +76,11 @@ function TabsPanel({ state, ...props }: TabsPanelProps) {
return (
<div
{...tabPanelProps}
ref={ref}
className={cn(
'w-full overflow-clip rounded-b-xl rounded-r-xl',
'border border-headplane-100 dark:border-headplane-800',
)}
ref={ref}
>
{state.selectedItem?.props.children}
</div>
+20 -11
View File
@@ -18,15 +18,26 @@ export function getErrorMessage(error: Error | unknown): {
const { statusCode, rawData, data, requestUrl } = error.data;
if (statusCode >= 500) {
return {
title: 'Cannot connect to Headscale API',
title: 'Headscale API Error',
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>
<>
<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>
{(error.data.data != null || error.data.rawData != null) && (
<pre className="mt-2 p-2 bg-headplane-100 dark:bg-headplane-800 rounded-lg overflow-x-auto">
{error.data.data != null ? (
<code>{JSON.stringify(error.data.data, null, 2)}</code>
) : (
<code>{error.data.rawData}</code>
)}
</pre>
)}
</>
),
};
}
@@ -124,7 +135,7 @@ export function getErrorMessage(error: Error | unknown): {
<br />
Status Code: <strong>{error.status}</strong>
<br />
Status Text: <strong>{error.statusText}</strong>
Status Text: <strong>{error.data}</strong>
</>
),
};
@@ -159,12 +170,10 @@ export function getErrorMessage(error: Error | unknown): {
// 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);
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ export function Layout({ children }: { readonly children: React.ReactNode }) {
<Links />
<link href="favicon.ico" rel="icon" />
</head>
<body className="overscroll-none dark:bg-headplane-900 dark:text-headplane-50">
<body className="overscroll-none overflow-x-hidden dark:bg-headplane-900 dark:text-headplane-50">
{children}
<ToastProvider queue={toastQueue} />
<ScrollRestoration />
+3
View File
@@ -5,6 +5,9 @@ export default [
index('routes/util/redirect.ts'),
route('/healthz', 'routes/util/healthz.ts'),
// API Routes
...prefix('/api', [route('/info', 'routes/util/info.ts')]),
// Authentication Routes
route('/login', 'routes/auth/login/page.tsx'),
route('/logout', 'routes/auth/logout.ts'),
+86 -55
View File
@@ -1,5 +1,5 @@
import { data } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
@@ -37,68 +37,99 @@ export async function aclAction({ request, context }: Route.ActionArgs) {
updatedAt,
});
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('update is disabled')) {
// This means the policy is not writable
if (isDataWithApiError(error)) {
const rawData = error.data.rawData;
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L11
if (rawData.includes('update is disabled')) {
throw data('Policy is not writable', { status: 403 });
}
// https://github.com/juanfont/headscale/blob/main/hscontrol/policy/v1/acls.go#L81
if (message.includes('parsing hujson')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err: hujson:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
const message =
error.data.data != null &&
'message' in error.data.data &&
typeof error.data.data.message === 'string'
? error.data.data.message
: undefined;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
if (message == null) {
throw error;
}
if (message.includes('unmarshalling policy')) {
// This means the policy was invalid, return a 400
// with the actual error message from Headscale
const cutIndex = message.indexOf('err:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
// Starting in Headscale 0.27.0 the ACLs parsing was changed meaning
// we need to reference other error messages based on API version.
if (context.hsApi.clientHelpers.isAtleast('0.27.0')) {
if (message.includes('parsing HuJSON:')) {
const cutIndex = message.indexOf('parsing HuJSON:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 16).trim()}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('empty policy')) {
return data(
{
success: false,
error: 'Policy error: Supplied policy was empty',
policy: undefined,
updatedAt: undefined,
},
400,
);
if (message.includes('parsing policy from bytes:')) {
const cutIndex = message.indexOf('parsing policy from bytes:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 26).trim()}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
} else {
// Pre-0.27.0 error messages
if (message.includes('parsing hujson')) {
const cutIndex = message.indexOf('err: hujson:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 12)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
if (message.includes('unmarshalling policy')) {
const cutIndex = message.indexOf('err:');
const trimmed =
cutIndex > -1
? `Syntax error: ${message.slice(cutIndex + 5)}`
: message;
return data(
{
success: false,
error: trimmed,
policy: undefined,
updatedAt: undefined,
},
400,
);
}
}
}
+6 -17
View File
@@ -1,5 +1,5 @@
import { data } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import { Capabilities } from '~/server/web/roles';
import type { Route } from './+types/overview';
@@ -30,30 +30,19 @@ export async function aclLoader({ request, context }: Route.LoaderArgs) {
const api = context.hsApi.getRuntimeClient(session.api_key);
try {
const { policy, updatedAt } = await api.getPolicy();
// Successfully loaded the policy, mark it as readable
// If `updatedAt` is null, it means the policy is in file mode.
flags.writable = updatedAt !== null;
flags.policy = policy;
return flags;
} catch (error) {
// This means Headscale returned a protobuf error to us
// It also means we 100% know this is in database mode
if (error instanceof ResponseError && error.responseObject?.message) {
const message = error.responseObject.message as string;
// This is stupid, refer to the link
// https://github.com/juanfont/headscale/blob/main/hscontrol/types/policy.go
if (message.includes('acl policy not found')) {
// This means the policy has never been initiated, and we can
// write to it to get it started or ignore it.
flags.policy = ''; // Start with an empty policy
if (isDataWithApiError(error)) {
// https://github.com/juanfont/headscale/blob/c4600346f9c29b514dc9725ac103efb9d0381f23/hscontrol/types/policy.go#L10
if (error.data.rawData.includes('acl policy not found')) {
flags.policy = '';
flags.writable = true;
return flags;
}
return flags;
}
// Otherwise, this is a Headscale error that we can just propagate.
throw error;
}
}
+56 -2
View File
@@ -1,11 +1,19 @@
import { Construction, Eye, FlaskConical, Pencil } from 'lucide-react';
import {
AlertCircle,
Construction,
Eye,
FlaskConical,
Pencil,
} from 'lucide-react';
import { useEffect, useState } from 'react';
import { useFetcher, useRevalidator } from 'react-router';
import { isRouteErrorResponse, useFetcher, useRevalidator } from 'react-router';
import Button from '~/components/Button';
import Card from '~/components/Card';
import Code from '~/components/Code';
import Link from '~/components/Link';
import Notice from '~/components/Notice';
import Tabs from '~/components/Tabs';
import { isApiError } from '~/server/headscale/api/error-client';
import toast from '~/utils/toast';
import type { Route } from './+types/overview';
import { aclAction } from './acl-action';
@@ -164,3 +172,49 @@ export default function Page({
</div>
);
}
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
if (
isRouteErrorResponse(error) &&
isApiError(error.data) &&
error.data.rawData.includes('reading policy from path') &&
error.data.rawData.includes('no such file or directory')
) {
return (
<div className="flex flex-col gap-4">
<Card className="max-w-2xl" variant="flat">
<div className="flex items-center justify-between gap-4">
<Card.Title>ACL Policy Unavailable</Card.Title>
<AlertCircle className="w-6 h-6 mb-2 text-red-500" />
</div>
<Card.Text>
The ACL policy is currently unavailable because the policy file does
not exist on the server. This usually indicates that Headscale is
running in <Code>file</Code> mode for ACLs, and the specified policy
file is missing.
</Card.Text>
</Card>
<Card className="max-w-2xl" variant="flat">
<Card.Text>
In order to resolve this issue, there are two possible actions you
can take:
</Card.Text>
<ul className="list-disc list-outside mt-2 ml-4 space-y-1 text-sm">
<li>
Create the ACL policy file at the specified path in your Headscale
configuration.
</li>
<li>
Alternatively, you can switch Headscale to use{' '}
<Code>database</Code> mode for ACLs by updating your Headscale
configuration. This will allow Headplane to manage the ACL policy
directly through the web interface.
</li>
</ul>
</Card>
</div>
);
}
throw error;
}
+8 -5
View File
@@ -1,5 +1,5 @@
import { data, redirect } from 'react-router';
import ResponseError from '~/server/headscale/api/response-error';
import { isDataWithApiError } from '~/server/headscale/api/error-client';
import log from '~/utils/log';
import type { Route } from './+types/page';
@@ -74,12 +74,15 @@ export async function loginAction({ request, context }: Route.LoaderArgs) {
},
});
} catch (error) {
if (error instanceof ResponseError) {
// Check if this is a React Router DataWithResponseInit wrapping a Headscale API error
if (isDataWithApiError(error)) {
const apiError = error.data;
// TODO: What in gods name is wrong with the headscale API?
if (
error.status === 401 ||
error.status === 403 ||
(error.status === 500 && error.response.trim() === 'Unauthorized')
apiError.statusCode === 401 ||
apiError.statusCode === 403 ||
(apiError.statusCode === 500 &&
apiError.rawData.trim() === 'Unauthorized')
) {
return {
success: false,
+37 -6
View File
@@ -121,20 +121,51 @@ export async function loader({ request, context }: Route.LoaderArgs) {
'Got an OIDC response error body: %s',
JSON.stringify(error.cause),
);
}
if (error instanceof oidc.AuthorizationResponseError) {
// Check for PKCE-related errors
if (
error.error.toLowerCase().includes('code_verifier') ||
error.error.toLowerCase().includes('code verifier') ||
error.error.toLowerCase().includes('pkce')
) {
log.error(
'auth',
'PKCE error detected. Your OIDC provider may require PKCE to be enabled. Current setting: use_pkce=%s',
context.oidcConnector?.usePKCE,
);
if (!context.oidcConnector?.usePKCE) {
log.error(
'auth',
'Consider setting oidc.use_pkce=true in your configuration if your provider requires PKCE',
);
}
}
} else if (error instanceof oidc.AuthorizationResponseError) {
log.error(
'auth',
'Got an OIDC authorization response error: %s',
error.error,
);
}
if (error instanceof oidc.WWWAuthenticateChallengeError) {
} else if (error instanceof oidc.WWWAuthenticateChallengeError) {
log.error('auth', 'Got an OIDC WWW-Authenticate challenge error');
} else if (error instanceof oidc.ClientError) {
log.error(
'auth',
'Got an OIDC authorization client error: %s',
error.cause instanceof Error
? error.cause.message
: String(error.cause),
);
} else {
log.error(
'auth',
'Got an OIDC error: %s',
error instanceof Error && error.cause
? JSON.stringify(error.cause)
: String(error),
);
}
return redirect('/login?s=error_auth_failed');
}
}
+23 -23
View File
@@ -1,18 +1,18 @@
import { DndContext, DragOverlay, closestCorners } from '@dnd-kit/core';
import { closestCorners, DndContext, DragOverlay } from '@dnd-kit/core';
import {
restrictToParentElement,
restrictToVerticalAxis,
} from '@dnd-kit/modifiers';
import {
SortableContext,
arrayMove,
SortableContext,
useSortable,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GripVertical, Lock } from 'lucide-react';
import { useEffect, useState } from 'react';
import { type FetcherWithComponents, Form, useFetcher } from 'react-router';
import { Form } from 'react-router';
import Button from '~/components/Button';
import Input from '~/components/Input';
import TableList from '~/components/TableList';
@@ -37,18 +37,15 @@ export default function ManageDomains({
}, [searchDomains]);
return (
<div className="flex flex-col w-2/3">
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Search Domains</h1>
<p className="mb-4">
Set custom DNS search domains for your Tailnet. When using Magic DNS,
your tailnet domain is used as the first search domain.
</p>
<DndContext
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
collisionDetection={closestCorners}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
modifiers={[restrictToVerticalAxis, restrictToParentElement]}
onDragEnd={(event) => {
setActiveId(null);
const { active, over } = event;
@@ -70,6 +67,9 @@ export default function ManageDomains({
setLocalDomains(arrayMove(localDomains, oldIndex, newIndex));
}
}}
onDragStart={(event) => {
setActiveId(event.active.id);
}}
>
<TableList>
{magic ? (
@@ -91,19 +91,19 @@ export default function ManageDomains({
>
{localDomains.map((sd, index) => (
<Domain
key={sd}
domain={sd}
id={index + 1}
isDisabled={isDisabled}
key={sd}
/>
))}
<DragOverlay adjustScale>
{activeId ? (
<Domain
isDragging
domain={localDomains[(activeId as number) - 1]}
id={(activeId as number) - 1}
isDisabled={isDisabled}
isDragging
/>
) : undefined}
</DragOverlay>
@@ -111,28 +111,28 @@ export default function ManageDomains({
{isDisabled ? undefined : (
<TableList.Item key="add-sd">
<Form
method="POST"
className="flex items-center justify-between w-full"
method="POST"
>
<input type="hidden" name="action_id" value="add_domain" />
<input name="action_id" type="hidden" value="add_domain" />
<Input
type="text"
className={cn(
'border-none font-mono p-0 text-sm',
'rounded-none focus:ring-0 w-full ml-1',
)}
placeholder="Search Domain"
label="Search Domain"
name="domain"
labelHidden
isRequired
label="Search Domain"
labelHidden
name="domain"
placeholder="Search Domain"
type="text"
/>
<Button
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-blue-500 dark:text-blue-400',
)}
type="submit"
>
Add
</Button>
@@ -164,11 +164,11 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
return (
<TableList.Item
ref={setNodeRef}
className={cn(
isSortableDragging ? 'opacity-50' : '',
isDragging ? 'ring-3 bg-white dark:bg-headplane-900' : '',
)}
ref={setNodeRef}
style={{
transform: CSS.Transform.toString(transform),
transition,
@@ -186,15 +186,15 @@ function Domain({ domain, id, isDragging, isDisabled }: DomainProps) {
</p>
{isDragging ? undefined : (
<Form method="POST">
<input type="hidden" name="action_id" value="remove_domain" />
<input type="hidden" name="domain" value={domain} />
<input name="action_id" type="hidden" value="remove_domain" />
<input name="domain" type="hidden" value={domain} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
+18 -17
View File
@@ -20,14 +20,14 @@ export default function ManageNS({
overrideLocalDns,
}: Props) {
return (
<div className="flex flex-col w-2/3">
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Nameservers</h1>
<p>
Set the nameservers used by devices on the Tailnet to resolve DNS
queries.{' '}
<Link
to="https://tailscale.com/kb/1054/dns"
name="Tailscale DNS Documentation"
to="https://tailscale.com/kb/1054/dns"
>
Learn more
</Link>
@@ -35,12 +35,12 @@ export default function ManageNS({
<div className="mt-4">
{Object.keys(nameservers).map((key) => (
<NameserverList
key={key}
isGlobal={key === 'global'}
isDisabled={isDisabled}
isGlobal={key === 'global'}
key={key}
name={key}
nameservers={nameservers}
overrideLocalDns={overrideLocalDns}
name={key}
/>
))}
@@ -66,16 +66,17 @@ function NameserverList({
name,
}: ListProps) {
const list = isGlobal ? nameservers.global : nameservers[name];
const submit = useSubmit();
if (list.length === 0) {
return null;
}
const submit = useSubmit();
return (
<div className="mb-8">
<div className="flex items-center justify-between mb-2">
{isGlobal ? (
<div className="flex items-center justify-between w-full">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between w-full gap-2">
<h2 className="text-md font-medium opacity-80">
Global Nameservers
</h2>
@@ -87,8 +88,8 @@ function NameserverList({
names outside the tailnet. When disabled (default), devices
will prefer their local DNS configuration.
<Link
to="https://tailscale.com/kb/1054/dns#global-nameservers"
name="Tailscale Global Nameservers Documentation"
to="https://tailscale.com/kb/1054/dns#global-nameservers"
>
Learn More
</Link>
@@ -96,11 +97,10 @@ function NameserverList({
</Tooltip>
<p>Override DNS servers</p>
<Switch
label="Override local DNS settings"
className="h-[15px] w-[23px] p-[2px]"
switchClassName="h-[9px] w-[9px]"
name="override_dns"
className="h-[15px] w-[23px] p-0.5"
defaultSelected={overrideLocalDns}
label="Override local DNS settings"
name="override_dns"
onChange={(v) => {
submit(
{
@@ -112,6 +112,7 @@ function NameserverList({
},
);
}}
switchClassName="h-[9px] w-[9px]"
/>
</div>
</div>
@@ -125,20 +126,20 @@ function NameserverList({
<TableList.Item key={ns}>
<p className="font-mono text-sm">{ns}</p>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_ns" />
<input type="hidden" name="ns" value={ns} />
<input name="action_id" type="hidden" value="remove_ns" />
<input name="ns" type="hidden" value={ns} />
<input
type="hidden"
name="split_name"
type="hidden"
value={isGlobal ? 'global' : name}
/>
<Button
isDisabled={isDisabled}
type="submit"
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
+13 -11
View File
@@ -13,14 +13,14 @@ interface Props {
export default function ManageRecords({ records, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3">
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">DNS Records</h1>
<p>
Headscale supports adding custom DNS records to your Tailnet. As of now,
only <Code>A</Code> and <Code>AAAA</Code> records are supported.{' '}
<Link
to="https://headscale.net/stable/ref/dns"
name="Headscale DNS Records documentation"
to="https://headscale.net/stable/ref/dns"
>
Learn More
</Link>
@@ -32,7 +32,7 @@ export default function ManageRecords({ records, isDisabled }: Props) {
<p className="opacity-50 mx-auto">No DNS records found</p>
</TableList.Item>
) : (
records.map((record, index) => (
records.map((record) => (
<TableList.Item key={`${record.name}-${record.value}`}>
<div className="flex gap-2 items-center w-full">
<p
@@ -43,22 +43,24 @@ export default function ManageRecords({ records, isDisabled }: Props) {
>
{record.type}
</p>
<div className="grid grid-cols-2 gap-2 w-full">
<p className="font-mono text-sm">{record.name}</p>
<p className="font-mono text-sm">{record.value}</p>
<div className="flex flex-col sm:flex-row sm:gap-2 flex-1 min-w-0">
<p className="font-mono text-sm truncate">{record.name}</p>
<p className="font-mono text-sm truncate opacity-70 sm:opacity-100">
{record.value}
</p>
</div>
</div>
<Form method="POST">
<input type="hidden" name="action_id" value="remove_record" />
<input type="hidden" name="record_name" value={record.name} />
<input type="hidden" name="record_type" value={record.type} />
<input name="action_id" type="hidden" value="remove_record" />
<input name="record_name" type="hidden" value={record.name} />
<input name="record_type" type="hidden" value={record.type} />
<Button
type="submit"
isDisabled={isDisabled}
className={cn(
'px-2 py-1 rounded-md',
'text-red-500 dark:text-red-400',
)}
isDisabled={isDisabled}
type="submit"
>
Remove
</Button>
+7 -7
View File
@@ -9,21 +9,21 @@ interface Props {
export default function RenameTailnet({ name, isDisabled }: Props) {
return (
<div className="flex flex-col w-2/3 gap-y-4">
<div className="flex flex-col w-full sm:w-2/3 gap-y-4">
<h1 className="text-2xl font-medium mb-2">Tailnet Name</h1>
<p>
This is the base domain name of your Tailnet. Devices are accessible at{' '}
<Code>[device].{name}</Code> when Magic DNS is enabled.
</p>
<Input
isReadOnly
labelHidden
className="w-3/5 font-medium text-sm"
isReadOnly
label="Tailnet name"
value={name}
labelHidden
onFocus={(event) => {
event.target.select();
}}
value={name}
/>
<Dialog>
<Dialog.Button isDisabled={isDisabled}>Rename Tailnet</Dialog.Button>
@@ -33,13 +33,13 @@ export default function RenameTailnet({ name, isDisabled }: Props) {
Keep in mind that changing this can lead to all sorts of unexpected
behavior and may break existing devices in your tailnet.
</Dialog.Text>
<input type="hidden" name="action_id" value="rename_tailnet" />
<input name="action_id" type="hidden" value="rename_tailnet" />
<Input
defaultValue={name}
isRequired
label="Tailnet name"
placeholder="ts.net"
defaultValue={name}
name="new_name"
placeholder="ts.net"
/>
</Dialog.Panel>
</Dialog>
+6 -6
View File
@@ -84,20 +84,20 @@ export default function Page() {
tailnet.
</Notice>
)}
<RenameTailnet name={data.baseDomain} isDisabled={isDisabled} />
<RenameTailnet isDisabled={isDisabled} name={data.baseDomain} />
<ManageNS
nameservers={allNs}
isDisabled={isDisabled}
nameservers={allNs}
overrideLocalDns={data.overrideDns}
/>
<ManageRecords records={data.extraRecords} isDisabled={isDisabled} />
<ManageRecords isDisabled={isDisabled} records={data.extraRecords} />
<ManageDomains
searchDomains={data.searchDomains}
isDisabled={isDisabled}
magic={data.magicDns ? data.baseDomain : undefined}
searchDomains={data.searchDomains}
/>
<div className="flex flex-col w-2/3">
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Magic DNS</h1>
<p className="mb-4">
Automatically register domain names for each device on the tailnet.
@@ -108,7 +108,7 @@ export default function Page() {
</Code>{' '}
when Magic DNS is enabled.
</p>
<ToggleMagic isEnabled={data.magicDns} isDisabled={isDisabled} />
<ToggleMagic isDisabled={isDisabled} isEnabled={data.magicDns} />
</div>
</div>
);
+23 -17
View File
@@ -13,6 +13,7 @@ import type { User } from '~/types';
import cn from '~/utils/cn';
import * as hinfo from '~/utils/host-info';
import { PopulatedNode } from '~/utils/node-info';
import { formatTimeDelta } from '~/utils/time';
import toast from '~/utils/toast';
import MenuOptions from './menu';
@@ -33,10 +34,7 @@ export default function MachineRow({
isDisabled,
existingTags,
}: Props) {
const uiTags = useMemo(() => {
const tags = uiTagsForNode(node, isAgent);
return tags;
}, [node, isAgent]);
const uiTags = useMemo(() => uiTagsForNode(node, isAgent), [node, isAgent]);
const ipOptions = useMemo(() => {
if (magic) {
@@ -129,22 +127,30 @@ export default function MachineRow({
</td>
) : undefined}
<td className="py-2">
<span
className={cn(
'flex items-center gap-x-1 text-sm',
'text-headplane-600 dark:text-headplane-300',
)}
>
<div className="flex items-start gap-x-1">
<StatusCircle
className="w-4 h-4"
className="w-4 h-4 mt-0.5"
isOnline={node.online && !node.expired}
/>
<p suppressHydrationWarning>
{node.online && !node.expired
? 'Connected'
: new Date(node.lastSeen).toLocaleString()}
</p>
</span>
<div>
<p
className={cn(
'text-sm',
'text-headplane-600 dark:text-headplane-300',
)}
suppressHydrationWarning
>
{node.online && !node.expired
? 'Connected'
: new Date(node.lastSeen).toLocaleString()}
</p>
{!(node.online && !node.expired) && (
<p className="text-xs opacity-50" suppressHydrationWarning>
{formatTimeDelta(new Date(node.lastSeen))}
</p>
)}
</div>
</div>
</td>
<td className="py-2 pr-0.5">
<MenuOptions
+2 -2
View File
@@ -11,11 +11,11 @@ interface MoveProps {
}
export default function Move({ machine, users, isOpen, setIsOpen }: MoveProps) {
const [userId, setUserId] = useState<Key | null>(null);
const [userId, setUserId] = useState<Key | null>(machine.user.id);
return (
<Dialog isOpen={isOpen} onOpenChange={setIsOpen}>
<Dialog.Panel>
<Dialog.Panel isDisabled={userId === machine.user.id}>
<Dialog.Title>Change the owner of {machine.givenName}</Dialog.Title>
<Dialog.Text>
The owner of the machine is the user associated with it.
+5 -1
View File
@@ -20,10 +20,12 @@ export default function NewMachine(data: NewMachineProps) {
const [mkey, setMkey] = useState('');
const navigate = useNavigate();
const isMkeyInvalid = mkey.length > 0 && mkey.length !== 24;
return (
<>
<Dialog isOpen={pushDialog} onOpenChange={setPushDialog}>
<Dialog.Panel isDisabled={mkey.length < 1}>
<Dialog.Panel isDisabled={mkey.length !== 24}>
<Dialog.Title>Register Machine Key</Dialog.Title>
<Dialog.Text className="mb-4">
The machine key is given when you run{' '}
@@ -32,6 +34,8 @@ export default function NewMachine(data: NewMachineProps) {
</Dialog.Text>
<input name="action_id" type="hidden" value="register" />
<Input
errorMessage="Machine key must be exactly 24 characters"
isInvalid={isMkeyInvalid}
isRequired
label="Machine Key"
name="register_key"
+299 -57
View File
@@ -1,5 +1,7 @@
import { Info } from 'lucide-react';
import { ChevronDown, ChevronUp, Info, X } from 'lucide-react';
import { useMemo, useState } from 'react';
import Code from '~/components/Code';
import Input from '~/components/Input';
import Link from '~/components/Link';
import Tooltip from '~/components/Tooltip';
import { Capabilities } from '~/server/web/roles';
@@ -66,11 +68,97 @@ export async function loader({ request, context }: Route.LoaderArgs) {
export const action = machineAction;
type SortField = 'name' | 'ip' | 'version' | 'lastSeen';
export default function Page({ loaderData }: Route.ComponentProps) {
const [searchQuery, setSearchQuery] = useState('');
const [sortField, setSortField] = useState<SortField>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const filteredAndSortedNodes = useMemo(() => {
const query = searchQuery.toLowerCase().trim();
let nodes = loaderData.populatedNodes.filter((node) => {
if (!query) return true;
if (node.givenName.toLowerCase().includes(query)) return true;
if (node.ipAddresses.some((ip) => ip.toLowerCase().includes(query)))
return true;
return false;
});
nodes = [...nodes].sort((a, b) => {
let comparison = 0;
switch (sortField) {
case 'name':
comparison = a.givenName.localeCompare(b.givenName);
break;
case 'ip': {
const getIPv4 = (addresses: string[]) =>
addresses.find((ip) => !ip.includes(':')) || addresses[0] || '';
const ipA = getIPv4(a.ipAddresses);
const ipB = getIPv4(b.ipAddresses);
if (!ipA.includes(':') && !ipB.includes(':')) {
const octetsA = ipA.split('.').map(Number);
const octetsB = ipB.split('.').map(Number);
for (let i = 0; i < 4; i++) {
if (octetsA[i] !== octetsB[i]) {
comparison = octetsA[i] - octetsB[i];
break;
}
}
} else {
comparison = ipA.localeCompare(ipB);
}
break;
}
case 'version': {
const versionA = a.hostInfo?.IPNVersion?.split('-')[0] || '0';
const versionB = b.hostInfo?.IPNVersion?.split('-')[0] || '0';
const partsA = versionA.split('.').map(Number);
const partsB = versionB.split('.').map(Number);
const maxLen = Math.max(partsA.length, partsB.length);
for (let i = 0; i < maxLen; i++) {
const segA = partsA[i] || 0;
const segB = partsB[i] || 0;
if (segA !== segB) {
comparison = segA - segB;
break;
}
}
break;
}
case 'lastSeen':
if (a.online !== b.online) {
comparison = a.online ? 1 : -1;
break;
}
comparison =
new Date(a.lastSeen).getTime() - new Date(b.lastSeen).getTime();
break;
}
return sortDirection === 'asc' ? comparison : -comparison;
});
return nodes;
}, [loaderData.populatedNodes, searchQuery, sortField, sortDirection]);
const handleSort = (field: SortField) => {
if (sortField === field) {
setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortField(field);
setSortDirection('asc');
}
};
return (
<>
<div className="flex justify-between items-center mb-6">
<div className="flex flex-col w-2/3">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<div className="flex flex-col">
<h1 className="text-2xl font-medium mb-2">Machines</h1>
<p>
Manage the devices connected to your Tailnet.{' '}
@@ -89,61 +177,215 @@ export default function Page({ loaderData }: Route.ComponentProps) {
users={loaderData.users}
/>
</div>
<table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th className="uppercase text-xs font-bold pb-2">Name</th>
<th className="pb-2 w-1/4">
<div className="flex items-center gap-x-1">
<p className="uppercase text-xs font-bold">Addresses</p>
{loaderData.magic ? (
<Tooltip>
<Info className="w-4 h-4" />
<Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based on
their name and also at{' '}
<Code>
[name].
{loaderData.magic}
</Code>
</Tooltip.Body>
</Tooltip>
) : undefined}
</div>
</th>
{/* We only want to show the version column if there are agents */}
{loaderData.agent !== undefined ? (
<th className="uppercase text-xs font-bold pb-2">Version</th>
) : undefined}
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
<div className="mb-4 flex items-center gap-4">
<div className="relative w-64">
<Input
label="Search machines"
labelHidden
maxLength={100}
onChange={(value) => setSearchQuery(value.slice(0, 100))}
placeholder="Search by name or IP address..."
value={searchQuery}
/>
{searchQuery && (
<button
aria-label="Clear search"
className={cn(
'absolute right-2 top-1/2 -translate-y-1/2',
'p-1 rounded-full',
'text-headplane-400 hover:text-headplane-600',
'dark:text-headplane-500 dark:hover:text-headplane-300',
'hover:bg-headplane-100 dark:hover:bg-headplane-800',
)}
onClick={() => setSearchQuery('')}
type="button"
>
<X className="w-4 h-4" />
</button>
)}
>
{loaderData.populatedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
isAgent={
loaderData.agent ? loaderData.agent === node.nodeKey : undefined
}
isDisabled={
loaderData.writable
? false // If the user has write permissions, they can edit all machines
: node.user.providerId?.split('/').pop() !==
loaderData.subject
}
key={node.id}
magic={loaderData.magic}
node={node}
users={loaderData.users}
/>
))}
</tbody>
</table>
</div>
<span className="text-sm text-headplane-500 whitespace-nowrap">
{searchQuery
? `Showing ${filteredAndSortedNodes.length} of ${loaderData.populatedNodes.length} machines`
: `${loaderData.populatedNodes.length} machines`}
</span>
</div>
<div className="overflow-x-auto">
<table className="table-auto w-full rounded-lg min-w-[640px]">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th
aria-sort={
sortField === 'name'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by name"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('name')}
type="button"
>
Name
{sortField === 'name' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
<th
aria-sort={
sortField === 'ip'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="pb-2 w-1/4"
>
<div className="flex items-center gap-x-1">
<button
aria-label="Sort by IP address"
className={cn(
'flex items-center gap-x-1 cursor-pointer uppercase text-xs font-bold',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('ip')}
type="button"
>
Addresses
{sortField === 'ip' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
{loaderData.magic ? (
<Tooltip>
<Info className="w-4 h-4" />
<Tooltip.Body className="font-normal">
Since MagicDNS is enabled, you can access devices based
on their name and also at{' '}
<Code>
[name].
{loaderData.magic}
</Code>
</Tooltip.Body>
</Tooltip>
) : undefined}
</div>
</th>
{/* We only want to show the version column if there are agents */}
{loaderData.agent !== undefined ? (
<th
aria-sort={
sortField === 'version'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by version"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('version')}
type="button"
>
Version
{sortField === 'version' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
) : undefined}
<th
aria-sort={
sortField === 'lastSeen'
? sortDirection === 'asc'
? 'ascending'
: 'descending'
: 'none'
}
className="uppercase text-xs font-bold pb-2"
>
<button
aria-label="Sort by last seen"
className={cn(
'flex items-center gap-x-1 cursor-pointer',
'hover:text-headplane-900 dark:hover:text-headplane-100',
)}
onClick={() => handleSort('lastSeen')}
type="button"
>
Last Seen
{sortField === 'lastSeen' &&
(sortDirection === 'asc' ? (
<ChevronUp className="w-3 h-3" />
) : (
<ChevronDown className="w-3 h-3" />
))}
</button>
</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{filteredAndSortedNodes.length === 0 ? (
<tr>
<td
className="py-8 text-center text-headplane-500"
colSpan={loaderData.agent !== undefined ? 5 : 4}
>
No machines found matching "{searchQuery}"
</td>
</tr>
) : (
filteredAndSortedNodes.map((node) => (
<MachineRow
existingTags={sortNodeTags(loaderData.nodes)}
isAgent={
loaderData.agent
? loaderData.agent === node.nodeKey
: undefined
}
isDisabled={
loaderData.writable
? false // If the user has write permissions, they can edit all machines
: node.user.providerId?.split('/').pop() !==
loaderData.subject
}
key={node.id}
magic={loaderData.magic}
node={node}
users={loaderData.users}
/>
))
)}
</tbody>
</table>
</div>
</>
);
}
+3 -3
View File
@@ -15,7 +15,7 @@ export default function Page({
}: Route.ComponentProps) {
return (
<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-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Settings</h1>
<p>
The settings page is still under construction. As I'm able to add more
@@ -23,7 +23,7 @@ export default function Page({
free to open an issue on the GitHub repository.
</p>
</div>
<div className="flex flex-col w-2/3">
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">Pre-Auth Keys</h1>
<p>
Headscale fully supports pre-authentication keys in order to easily
@@ -45,7 +45,7 @@ export default function Page({
</RemixLink>
{config && isOidcEnabled ? (
<>
<div className="flex flex-col w-2/3">
<div className="flex flex-col w-full sm:w-2/3">
<h1 className="text-2xl font-medium mb-4">
Authentication Restrictions
</h1>
@@ -43,7 +43,7 @@ export default function Page({
return (
<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-full sm:w-2/3">
<p className="mb-4 text-md">
<RemixLink className="font-medium" to="/settings">
Settings
+2 -2
View File
@@ -19,7 +19,7 @@ export default function RestrictionTable({
isDisabled,
}: RestrictionProps) {
return (
<div className="w-2/3">
<div className="w-full sm:w-2/3">
<h2 className="text-2xl font-medium mt-8">
Permitted {type.charAt(0).toUpperCase() + type.slice(1)}s
</h2>
@@ -61,7 +61,7 @@ export default function RestrictionTable({
) : (
<TableList.Item className="flex flex-col items-center gap-2.5 py-4 opacity-70">
{iconForType(type)}
<p className="font-semibold">
<p className="font-semibold text-center">
All {type}s are permitted to authenticate.
</p>
</TableList.Item>
+41 -26
View File
@@ -1,3 +1,4 @@
import { createHash } from 'node:crypto';
import { useEffect, useState } from 'react';
import { Capabilities } from '~/server/web/roles';
import type { Machine, User } from '~/types';
@@ -32,6 +33,18 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const users = apiUsers.map((user) => ({
...user,
machines: nodes.filter((node) => node.user.id === user.id),
profilePicUrl:
context.config.oidc?.profile_picture_source === 'gravatar'
? (() => {
if (!user.email) {
return undefined;
}
const emailHash = user.email.trim().toLowerCase();
const hash = createHash('sha256').update(emailHash).digest('hex');
return `https://www.gravatar.com/avatar/${hash}?s=200&d=identicon&r=x`;
})()
: user.profilePicUrl,
}));
const roles = await Promise.all(
@@ -101,32 +114,34 @@ export default function Page({ loaderData }: Route.ComponentProps) {
Manage the users in your network and their permissions.
</p>
<ManageBanner isDisabled={!loaderData.writable} oidc={loaderData.oidc} />
<table className="table-auto w-full rounded-lg">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th className="uppercase text-xs font-bold pb-2">User</th>
<th className="uppercase text-xs font-bold pb-2">Role</th>
<th className="uppercase text-xs font-bold pb-2">Created At</th>
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{users
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => (
<UserRow
key={user.id}
role={loaderData.roles[users.indexOf(user)]}
user={user}
/>
))}
</tbody>
</table>
<div className="overflow-x-auto">
<table className="table-auto w-full rounded-lg min-w-[640px]">
<thead className="text-headplane-600 dark:text-headplane-300">
<tr className="text-left px-0.5">
<th className="uppercase text-xs font-bold pb-2">User</th>
<th className="uppercase text-xs font-bold pb-2">Role</th>
<th className="uppercase text-xs font-bold pb-2">Created At</th>
<th className="uppercase text-xs font-bold pb-2">Last Seen</th>
</tr>
</thead>
<tbody
className={cn(
'divide-y divide-headplane-100 dark:divide-headplane-800 align-top',
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{users
.sort((a, b) => a.name.localeCompare(b.name))
.map((user) => (
<UserRow
key={user.id}
role={loaderData.roles[users.indexOf(user)]}
user={user}
/>
))}
</tbody>
</table>
</div>
</>
);
}
+59
View File
@@ -0,0 +1,59 @@
import { versions } from 'node:process';
import { data } from 'react-router';
import type { Route } from './+types/info';
export async function loader({ request, context }: Route.LoaderArgs) {
if (context.config.server.info_secret == null) {
throw data(
{
status: 'Forbidden',
},
403,
);
}
const bearer = request.headers.get('Authorization') ?? '';
if (!bearer.startsWith('Bearer ')) {
throw data(
{
status: 'Unauthorized',
},
401,
);
}
const token = bearer.slice('Bearer '.length).trim();
if (token !== context.config.server.info_secret) {
throw data(
{
status: 'Forbidden',
},
403,
);
}
// Use a fake API key for healthcheck
const api = context.hsApi.getRuntimeClient('fake-api-key');
const healthy = await api.isHealthy();
const body = {
status: healthy ? 'healthy' : 'unhealthy',
headplane_version: __VERSION__,
headscale_canonical_version: healthy ? context.hsApi.apiVersion : 'unknown',
internal_versions: {
node: versions.node,
v8: versions.v8,
uv: versions.uv,
zlib: versions.zlib,
openssl: versions.openssl,
libc: versions.libc,
},
};
return new Response(JSON.stringify(body), {
status: 200,
headers: {
'Content-Type': 'application/json',
},
});
}
+6 -6
View File
@@ -17,6 +17,7 @@ const serverConfig = type({
port: 'number.integer = 3000',
base_url: 'string.url?',
data_path: 'string.lower = "/var/lib/headplane/"',
info_secret: 'string?',
cookie_secret: '(32 <= string <= 32)',
cookie_secure: 'boolean = true',
@@ -29,6 +30,7 @@ const partialServerConfig = type({
port: 'number.integer?',
base_url: 'string.url?',
data_path: 'string.lower?',
info_secret: 'string?',
cookie_secret: '(32 <= string <= 32)?',
cookie_secure: 'boolean?',
@@ -99,13 +101,12 @@ const oidcConfig = type({
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// 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({
@@ -123,13 +124,12 @@ const partialOidcConfig = type({
authorization_endpoint: 'string.url?',
token_endpoint: 'string.url?',
userinfo_endpoint: 'string.url?',
token_endpoint_auth_method:
'"client_secret_basic" | "client_secret_post" | "client_secret_jwt"?',
// 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({
+27
View File
@@ -44,6 +44,13 @@ export function isConnectionError(
);
}
/**
* Type guard to check if an error is a DataUnauthorizedError.
* This checks if the error has a `data` property with a `statusCode` of 401.
*
* @param error - The error to check.
* @returns True if the error is a DataUnauthorizedError, false otherwise.
*/
export function isDataUnauthorizedError(error: unknown): boolean {
return (
error != null &&
@@ -55,3 +62,23 @@ export function isDataUnauthorizedError(error: unknown): boolean {
error.data.statusCode === 401
);
}
/**
* Type guard to check if an error is a DataWithResponseInit wrapping a
* HeadscaleAPIError. This is used in loaders/actions to handle errors thrown by
* `data()` before they reach the ErrorBoundary.
*
* @param error - The error to check.
* @returns True if the error is a DataWithResponseInit containing a
* HeadscaleAPIError, false otherwise.
*/
export function isDataWithApiError(
error: unknown,
): error is { data: HeadscaleAPIError } {
return (
error != null &&
typeof error === 'object' &&
'data' in error &&
isApiError(error.data)
);
}
@@ -1,21 +0,0 @@
// Represents an error that occurred during a response
// Thrown when status codes are >= 400
export default class ResponseError extends Error {
status: number;
response: string;
requestUrl: string;
responseObject?: Record<string, unknown>;
constructor(status: number, response: string, requestUrl: string) {
super(`${requestUrl}: status ${status} - ${response}`);
this.name = 'ResponseError';
this.status = status;
this.response = response;
this.requestUrl = requestUrl;
try {
// Try to parse the response as JSON to get a response object
this.responseObject = JSON.parse(response);
} catch {}
}
}
+47
View File
@@ -200,6 +200,7 @@ async function discoveryCoalesce(
const oidcClient = new oidc.Configuration(
{
...metadata,
issuer: config.issuer,
authorization_endpoint,
token_endpoint,
@@ -207,7 +208,53 @@ async function discoveryCoalesce(
},
config.client_id,
config.client_secret,
negotiateTokenEndpointAuthMethod(config, metadata),
);
return oidcClient;
}
/**
* Determines the token endpoint authentication method based on config and metadata.
*
* @param config The OIDC configuration.
* @param metadata The OIDC server metadata.
* @returns The client authentication method for the token endpoint.
*/
function negotiateTokenEndpointAuthMethod(
config: OidcConfig,
metadata: oidc.ServerMetadata,
): oidc.ClientAuth {
if (config.token_endpoint_auth_method != null) {
switch (config.token_endpoint_auth_method) {
case 'client_secret_basic':
return oidc.ClientSecretBasic(config.client_secret);
case 'client_secret_post':
return oidc.ClientSecretPost(config.client_secret);
case 'client_secret_jwt':
return oidc.ClientSecretJwt(config.client_secret);
}
}
const supported = metadata.token_endpoint_auth_methods_supported;
if (supported != null && supported.length > 0) {
// Prefer client_secret_basic (spec default), otherwise use first available
if (supported.includes('client_secret_basic')) {
return oidc.ClientSecretBasic(config.client_secret);
}
if (supported.includes('client_secret_post')) {
return oidc.ClientSecretPost(config.client_secret);
}
if (supported.includes('client_secret_jwt')) {
return oidc.ClientSecretJwt(config.client_secret);
}
}
log.warn(
'config',
'Falling back to client_secret_basic for token endpoint authentication',
);
return oidc.ClientSecretBasic(config.client_secret);
}
+1
View File
@@ -65,6 +65,7 @@
@utility container {
margin-inline: auto;
padding-inline: 1rem;
max-width: 100%;
/* biome-ignore lint/correctness/noUnknownFunction: Tailwind CSS */
@media (width >= theme(--breakpoint-sm)) {
+42
View File
@@ -0,0 +1,42 @@
/**
* Formats the time delta since a given date into a human-readable string.
* - Under 1 hour: "X minutes ago"
* - Under 1 day: "X hours, Y minutes ago"
* - Under 1 month: "X days, Y hours ago"
* - Over 1 month: "X months, Y days ago"
*/
export function formatTimeDelta(date: Date): string {
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const minutes = Math.floor(diffMs / (1000 * 60));
const hours = Math.floor(diffMs / (1000 * 60 * 60));
const days = Math.floor(diffMs / (1000 * 60 * 60 * 24));
const months = Math.floor(days / 30);
if (minutes < 60) {
return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;
}
if (hours < 24) {
const remainingMinutes = minutes % 60;
if (remainingMinutes === 0) {
return `${hours} hour${hours !== 1 ? 's' : ''} ago`;
}
return `${hours} hour${hours !== 1 ? 's' : ''}, ${remainingMinutes} minute${remainingMinutes !== 1 ? 's' : ''} ago`;
}
if (days < 30) {
const remainingHours = hours % 24;
if (remainingHours === 0) {
return `${days} day${days !== 1 ? 's' : ''} ago`;
}
return `${days} day${days !== 1 ? 's' : ''}, ${remainingHours} hour${remainingHours !== 1 ? 's' : ''} ago`;
}
const remainingDays = days % 30;
if (remainingDays === 0) {
return `${months} month${months !== 1 ? 's' : ''} ago`;
}
return `${months} month${months !== 1 ? 's' : ''}, ${remainingDays} day${remainingDays !== 1 ? 's' : ''} ago`;
}
+20 -1
View File
@@ -16,6 +16,7 @@ BUILD_WASM=0
BUILD_APP=0
BUILD_AGENT=0
BUILD_FAKE_SHELL=0
BUILD_HEALTHCHECK=0
SKIP_PATH_CHECKS=0
SKIP_PNPM_PRUNE=0
APP_INSTALL_ONLY=0
@@ -23,6 +24,7 @@ APP_INSTALL_ONLY=0
WASM_OUTPUT="$PUBLIC_DIR/hp_ssh.wasm"
AGENT_OUTPUT="$BUILD_DIR/hp_agent"
FAKE_SHELL_OUTPUT="$BUILD_DIR/hp_fake_sh"
HEALTHCHECK_OUTPUT="$BUILD_DIR/hp_healthcheck"
die() { echo "error: $*" >&2; exit 1; }
run() { echo ">> $*"; "$@"; }
@@ -33,6 +35,7 @@ while [ $# -gt 0 ]; do
--app) BUILD_APP=1 ;;
--agent) BUILD_AGENT=1 ;;
--fake-shell) BUILD_FAKE_SHELL=1 ;;
--healthcheck) BUILD_HEALTHCHECK=1 ;;
--skip-path-checks) SKIP_PATH_CHECKS=1 ;;
--skip-pnpm-prune) SKIP_PNPM_PRUNE=1 ;;
--app-install-only) APP_INSTALL_ONLY=1 ;;
@@ -55,6 +58,12 @@ while [ $# -gt 0 ]; do
FAKE_SHELL_OUTPUT=$1
;;
--healthcheck-output)
shift
[ $# -gt 0 ] || die "--healthcheck-output requires a path"
HEALTHCHECK_OUTPUT=$1
;;
--help)
cat <<EOF
Usage: $0 [flags]
@@ -62,12 +71,14 @@ Usage: $0 [flags]
--app build react-router app
--agent build tailscale agent
--fake-shell build fake shell binary (for Docker)
--healthcheck build healthcheck binary
--skip-path-checks skip safety checks (ie. checking PATH)
--skip-pnpm-prune skip pruning devDependencies from node_modules
--app-install-only only install app dependencies, skip build
--wasm-output <path> override wasm output path
--agent-output <path> override agent output path
--fake-shell-output <path> override fake shell output path
--healthcheck-output <path> override healthcheck output path
EOF
exit 0
;;
@@ -84,6 +95,8 @@ if [ "$BUILD_WASM" -eq 0 ] && [ "$BUILD_APP" -eq 0 ] && \
BUILD_WASM=1
BUILD_APP=1
BUILD_AGENT=1
BUILD_HEALTHCHECK=1
BUILD_FAKE_SHELL=0
fi
if [ "$SKIP_PATH_CHECKS" -eq 0 ]; then
@@ -96,6 +109,7 @@ if [ "$SKIP_PATH_CHECKS" -eq 0 ]; then
[ "$BUILD_APP" -eq 1 ] && need_pnpm=1
[ "$BUILD_AGENT" -eq 1 ] && need_go=1
[ "$BUILD_FAKE_SHELL" -eq 1 ] && need_go=1
[ "$BUILD_HEALTHCHECK" -eq 1 ] && need_go=1
if [ $need_go -eq 1 ]; then
echo "==> Checking for Go toolchain"
@@ -160,11 +174,16 @@ build_fake_shell() {
-o "$FAKE_SHELL_OUTPUT" ./cmd/fake_sh
}
[ "$BUILD_FAKE_SHELL" = 1 ] && build_fake_shell
build_healthcheck() {
echo "==> Building healthcheck binary → $HEALTHCHECK_OUTPUT"
mkdir -p "$(dirname "$HEALTHCHECK_OUTPUT")"
go build -o "$HEALTHCHECK_OUTPUT" ./cmd/hp_healthcheck
}
[ "$BUILD_WASM" = 1 ] && build_wasm
[ "$BUILD_APP" = 1 ] && build_app
[ "$BUILD_AGENT" = 1 ] && build_agent
[ "$BUILD_FAKE_SHELL" = 1 ] && build_fake_shell
[ "$BUILD_HEALTHCHECK" = 1 ] && build_healthcheck
echo "✅ Build complete."
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"fmt"
"net/http"
"os"
"time"
)
func main() {
client := http.Client{
Timeout: 5 * time.Second,
}
resp, err := client.Get("http://localhost:3000/admin/healthz")
if err != nil {
fmt.Fprintf(os.Stderr, "Health check failed: %v\n", err)
os.Exit(1)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
fmt.Fprintf(os.Stderr, "Health check returned non-OK status: %d\n", resp.StatusCode)
os.Exit(1)
}
fmt.Println("Health check passed.")
os.Exit(0)
}
+18 -3
View File
@@ -35,6 +35,16 @@ server:
# PLEASE ensure this directory is mounted if running in Docker.
data_path: "/var/lib/headplane"
# The info secret is optional and allows access to certain debug endpoints
# that may expose sensitive information about your Headplane instance.
#
# As of now, this protects the /api/info endpoint which exposes details about
# the Headplane and Headscale versions in use. In the future, more endpoints
# may be protected by this secret.
#
# If not set, these endpoints will be disabled.
# info_secret: "<change_me_to_something_secure!>"
# Headscale specific settings to allow Headplane to talk
# to Headscale and access deep integration features
headscale:
@@ -154,9 +164,9 @@ integration:
proc:
enabled: false
# OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience)
# oidc:
# OIDC Configuration for simpler authentication
# (This is optional, but recommended for the best experience)
# oidc:
# The OIDC issuer URL
# issuer: "https://accounts.google.com"
@@ -175,6 +185,11 @@ integration:
# token_endpoint: ""
# userinfo_endpoint: ""
# The authentication method to use when communicating with the token endpoint.
# This is fully optional and Headplane will attempt to auto-detect the best
# method and fall back to `client_secret_basic` if unsure.
# token_endpoint_auth_method: "client_secret_post"
# The client ID for the OIDC client
# For the best experience please ensure this is *identical* to the client_id
# you are using for Headscale. because
+7
View File
@@ -11,6 +11,9 @@ export default defineConfig({
{ text: 'Home', link: '/' },
{ text: 'Changelog', link: '/CHANGELOG' },
],
search: {
provider: 'local',
},
sidebar: [
{
text: 'Getting Started',
@@ -30,6 +33,10 @@ export default defineConfig({
link: '/configuration',
items: [
{ text: 'Common Issues', link: '/configuration/common-issues' },
{
text: 'Sensitive Values',
link: '/configuration#sensitive-values',
},
],
},
{ text: 'Nix', link: '/Nix' },
+56 -45
View File
@@ -1,6 +1,7 @@
---
title: Single Sign-On (SSO)
description: Configure Single Sign-On (SSO) authentication for Headplane.
outline: [2, 3]
---
# Single Sign-On (SSO)
@@ -11,66 +12,68 @@ description: Configure Single Sign-On (SSO) authentication for Headplane.
<figcaption>SSO Configuration Page</figcaption>
</figure>
Headplane supports Single Sign-On (SSO) authentication using OpenID Connect
(OIDC). This allows users to authenticate using an external Identity Provider
(IdP) that supports OIDC, streamlining the login process and enhancing security.
Single Sign-On allows users to authenticate with Headplane through an external
Identity Provider (IdP). It does this using the OpenID Connect (OIDC) protocol,
which is widely supported by many popular IdPs.
This is generally the recommended authentication method when using Headplane in
production environments as it provides the deepest integration with Headscale
and allows for seamless user management.
## Getting Started
To set up Single Sign-On (SSO) with Headplane, there are several steps involved.
As a general recommendation, please read through the entire guide before
beginning the process as there are several important factors to consider.
## Configuring OIDC
To configure Single Sign-On (SSO) you'll need to first setup a client with your
Identity Provider that supports OIDC. The exact steps to do this will vary, but
generally you'll need to be able to provide the following information to
Headplane:
### Requirements
| Field | Description |
|---------------------------|--------------------------------------------------|
| **Client ID** | The client identifier provided by your IdP. |
| **Client Secret** | The client secret provided by your IdP. |
| **Issuer URL** | The OIDC issuer URL given by your IdP. |
::: warning
If you are also using OpenID Connect (OIDC) authentication with Headscale, it is
**fundamentally important** that both Headscale and Headplane are configured to
use the *exact same client* in your Identity Provider (IdP). This means that
both services should share the same client ID and secret.
::: tip
If you are using a custom prefix other than `/admin` for the Headplane web UI,
please ensure that you adjust the redirect URL accordingly when setting up
your OIDC client.
This is necessary because Headplane relies on the user IDs provided by the IdP
to match users with their equivalent Headscale users. If Headscale and Headplane
are using different clients, the user IDs may not match up correctly, preventing
a user from viewing their devices in Headplane.
:::
Before creating the client, configure Headplane to use a redirect URL that your
IdP will accept. You'll need to set **`server.base_url`** to the public URL of your
Headplane instance in your configuration file. For example, if your Headplane
instance runs on `https://headplane.example.com/admin`, set:
You'll need the following things set up before proceeding:
- A working Headplane installation that is already configured.
- An Identity Provider (IdP) that supports OAuth2 and OpenID Connect (OIDC).
- `server.base_url` set to the public URL of your Headplane instance in your
configuration file (ie. the domain that's visible in the browser).
- A Headscale API key with a relatively long expiration time (eg. 1 year).
```yaml
server:
base_url: "https://headplane.example.com"
```
### Configuring the Client
You'll need to create a client in your Identity Provider (IdP) that Headplane
can use for authentication. A part of that step involves giving an allowed
"redirect URL" to your IdP. This URL is where the IdP will send users back to
after they have authenticated.
and provide the following redirect URL to your IdP when creating the client:
For Headplane, the redirect URL will be in the following format, where the
domain is replaced with the value set for `server.base_url` in your Headplane
configuration:
```
https://headplane.example.com/admin/auth/callback
https://headplane.example.com/admin/oidc/callback
```
### Headscale API Key
Once you have created the client with your IdP, you'll need to generate an API
key for Headplane to use when communicating with Headscale. You can do this by
running `headscale apikeys create -e 1y` to create an API key that is valid for
one year (you can adjust the expiration as needed). Make sure to copy the
generated API key as you will need it for the Headplane configuration.
Once you have created the client in your IdP, make note of the following
information as you'll need it for the Headplane configuration:
- Client ID
- Client Secret (if applicable)
- Issuer URL
### Headplane Configuration
Finally, you can configure Headplane to use OIDC by adding the following fields
to your Headplane configuration file:
### OIDC Configuration
To enable OIDC authentication in Headplane, you'll need to add the necessary
configuration options via the file or environment variables. See below:
```yaml
oidc:
headscale_api_key: "<generated-api-key>"
issuer: "https://your-idp.com"
client_id: "your-client-id"
client_secret: "your-client-secret"
headscale_api_key: "<generated-api-key>"
# You can also provide the client secret via a file:
# client_secret_path: "${HOME}/secrets/headplane_oidc_client_secret.txt"
# Those options should generally be sufficient, but you can also set these:
# authorization_endpoint: ""
@@ -82,10 +85,21 @@ oidc:
# baz: "qux"
```
Headplane automatically tries to discover the necessary OIDC endpoints but if
your IdP does not support discovery, you may need to manually specify them.
### PKCE
::: warning
Headplane currently only supports the **`S256`** code challenge method for PKCE.
You may need to ensure that your Identity Provider is configured to accept this
method.
:::
By default, Headplane does not use PKCE (Proof Key for Code Exchange) when
communicating with the Identity Provider. PKCE is generally a best practice for
OIDC and can enhance security. To enable PKCE you'll need to set `oidc.use_pkce`
OIDC and can enhance security. *Some Identity Providers may even require PKCE
to be used.* To enable PKCE you'll need to set `oidc.use_pkce`
to `true` in your Headplane configuration file:
```yaml
@@ -93,9 +107,6 @@ oidc:
use_pkce: true
```
You'll also need to ensure that your Identity Provider supports PKCE and is
properly configured to handle PKCE requests from Headplane.
## Troubleshooting
Some of the common issues you may encounter when configuring OIDC with Headplane
include:
+22
View File
@@ -39,6 +39,28 @@ It's important to mount your configuration file and also provide a persistent
storage location for Headplane to store its own data. You can also change the
port mapping if you want to run it on a different port.
## Health Checks
The Docker image includes a built-in healthcheck that verifies the Headplane
server is running and responding. Docker will automatically monitor the
container and report its health status. No additional configuration is required.
The healthcheck binary is located at `/bin/hp_healthcheck` inside the container.
If you need to override the default healthcheck behavior, you can do so in your
`compose.yaml`:
```yaml
services:
headplane:
image: ghcr.io/tale/headplane:latest
healthcheck:
test: ["/bin/hp_healthcheck"]
interval: 30s
timeout: 5s
start_period: 5s
retries: 3
```
## Accessing Headplane
After starting the container, you can access the Headplane web interface by
Generated
+6 -6
View File
@@ -7,11 +7,11 @@
]
},
"locked": {
"lastModified": 1762521437,
"narHash": "sha256-RXN+lcx4DEn3ZS+LqEJSUu/HH+dwGvy0syN7hTo/Chg=",
"lastModified": 1764011051,
"narHash": "sha256-M7SZyPZiqZUR/EiiBJnmyUbOi5oE/03tCeFrTiUZchI=",
"owner": "numtide",
"repo": "devshell",
"rev": "07bacc9531f5f4df6657c0a02a806443685f384a",
"rev": "17ed8d9744ebe70424659b0ef74ad6d41fc87071",
"type": "github"
},
"original": {
@@ -40,11 +40,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1763191728,
"narHash": "sha256-esRhOS0APE6k40Hs/jjReXg+rx+J5LkWw7cuWFKlwYA=",
"lastModified": 1768032153,
"narHash": "sha256-6kD1MdY9fsE6FgSwdnx29hdH2UcBKs3/+JJleMShuJg=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "1d4c88323ac36805d09657d13a5273aea1b34f0c",
"rev": "3146c6aa9995e7351a398e17470e15305e6e18ff",
"type": "github"
},
"original": {