Compare commits

...

24 Commits

Author SHA1 Message Date
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
Aarnav Tale 3a3e5ca65e feat: pkce 2025-12-04 11:37:22 -05:00
Aarnav Tale 3eff763436 docs: create initial sso page 2025-12-04 10:52:16 -05:00
Aarnav Tale 6ad0847653 fix: the login page would crash if OIDC is not enabled correctly 2025-12-04 10:39:24 -05:00
Aarnav Tale 199ef46ee1 fix: correctly passthrough and handle a new OIDC redirect_uri system 2025-12-04 10:34:48 -05:00
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
27 changed files with 913 additions and 237 deletions
+5
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)).
@@ -10,10 +11,14 @@
- 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)).
- 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)).
---
# 0.6.1 (October 12, 2025)
+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);
}
}
+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,
+1 -1
View File
@@ -24,7 +24,7 @@ export function OidcConfigErrorNotice({
</ul>{' '}
<Link
name="Headplane OIDC Issues"
to="https://headplane.net/configuration/sso#help"
to="https://headplane.net/configuration/sso#troubleshooting"
>
Learn more
</Link>
+1 -1
View File
@@ -47,7 +47,7 @@ function getErrorMessage(code: string) {
case 'error_auth_failed':
return (
<Card.Text>
Authentication with the SSO provider failed. Please tray again later.
Authentication with the SSO provider failed. Please try again later.
Headplane logs may provide more information.
</Card.Text>
);
+1 -3
View File
@@ -1,7 +1,6 @@
import { AlertCircle } from 'lucide-react';
import { useEffect, useState } from 'react';
import {
data,
Form,
Link as RemixLink,
redirect,
@@ -12,7 +11,6 @@ import Card from '~/components/Card';
import Code from '~/components/Code';
import Input from '~/components/Input';
import Link from '~/components/Link';
import { OidcConnectorError } from '~/server/web/oidc-connector';
import { useLiveData } from '~/utils/live-data';
import type { Route } from './+types/page';
import { loginAction } from './action';
@@ -37,7 +35,7 @@ export async function loader({ request, context }: Route.LoaderArgs) {
const isOidcConnectorEnabled = context.oidcConnector?.isValid;
const oidcErrorCodes = !isOidcConnectorEnabled
? context.oidcConnector!.errors
? (context.oidcConnector?.errors ?? [])
: [];
return {
+31 -33
View File
@@ -1,23 +1,15 @@
import { createHash } from 'node:crypto';
import { count, eq } from 'drizzle-orm';
import * as oidc from 'openid-client';
import {
createCookie,
data,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import { data, redirect } from 'react-router';
import { ulid } from 'ulidx';
import type { LoadContext } from '~/server';
import { users } from '~/server/db/schema';
import { Roles } from '~/server/web/roles';
import log from '~/utils/log';
import type { OidcCookieState } from './oidc-start';
import { createOidcStateCookie } from '~/utils/oidc-state';
import type { Route } from './+types/oidc-callback';
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
if (!context.oidcConnector?.isValid) {
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
@@ -27,35 +19,34 @@ export async function loader({
return redirect('/login?s=error_no_query');
}
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300,
secure: context.config.server.cookie_secure,
domain: context.config.server.cookie_domain,
});
const cookie = createOidcStateCookie(context.config);
const oidcCookieState = await cookie.parse(request.headers.get('Cookie'));
const oidcCookieState: OidcCookieState | null = await cookie.parse(
request.headers.get('Cookie'),
);
if (oidcCookieState == null || typeof oidcCookieState !== 'object') {
if (oidcCookieState == null) {
log.warn('auth', 'Called OIDC callback without session cookie');
return redirect('/login?s=error_no_session');
}
const { state, nonce } = oidcCookieState;
if (!state || !nonce) {
const { state, nonce, redirect_uri, verifier } = oidcCookieState;
if (!state || !nonce || !redirect_uri || !verifier) {
log.warn('auth', 'OIDC session cookie is missing required fields');
return redirect('/login?s=error_invalid_session');
}
try {
const callbackUrl = new URL(redirect_uri);
const currentUrl = new URL(request.url);
callbackUrl.search = currentUrl.search;
const tokens = await oidc.authorizationCodeGrant(
context.oidcConnector.client,
request,
callbackUrl,
{
expectedState: state,
expectedNonce: nonce,
...(context.oidcConnector.usePKCE
? { pkceCodeVerifier: verifier }
: {}),
},
);
@@ -130,20 +121,27 @@ export async function loader({
'Got an OIDC response error body: %s',
JSON.stringify(error.cause),
);
}
if (error instanceof oidc.AuthorizationResponseError) {
} 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.message,
);
} else {
log.error(
'auth',
'Got an OIDC error: %s',
JSON.stringify(error.cause),
);
}
return redirect('/login?s=error_auth_failed');
}
}
+32 -28
View File
@@ -1,21 +1,10 @@
import * as oidc from 'openid-client';
import {
createCookie,
data,
type LoaderFunctionArgs,
redirect,
} from 'react-router';
import type { LoadContext } from '~/server';
import { data, redirect } from 'react-router';
import { HeadplaneConfig } from '~/server/config/config-schema';
import { createOidcStateCookie } from '~/utils/oidc-state';
import type { Route } from './+types/oidc-start';
export interface OidcCookieState {
nonce: string;
state: string;
}
export async function loader({
request,
context,
}: LoaderFunctionArgs<LoadContext>) {
export async function loader({ request, context }: Route.LoaderArgs) {
try {
await context.sessions.auth(request);
return redirect('/');
@@ -25,25 +14,25 @@ export async function loader({
throw data('OIDC is not enabled or misconfigured', { status: 501 });
}
const cookie = createCookie('__oidc_auth_flow', {
httpOnly: true,
maxAge: 300,
secure: context.config.server.cookie_secure,
domain: context.config.server.cookie_domain,
});
const redirectUri =
context.config.oidc?.redirect_uri ?? getRedirectUri(request);
const cookie = createOidcStateCookie(context.config);
const redirect_uri = getRedirectUri(context.config, request);
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: redirectUri,
redirect_uri,
state,
nonce,
...(context.oidcConnector.usePKCE
? {
code_challenge_method: 'S256',
code_challenge: await oidc.calculatePKCECodeChallenge(verifier),
}
: {}),
});
return redirect(url.href, {
@@ -52,12 +41,27 @@ export async function loader({
'Set-Cookie': await cookie.serialize({
state,
nonce,
} satisfies OidcCookieState),
verifier,
redirect_uri,
}),
},
});
}
function getRedirectUri(req: Request) {
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) {
+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.
+264 -24
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,7 +68,93 @@ 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">
@@ -89,13 +177,98 @@ export default function Page({ loaderData }: Route.ComponentProps) {
users={loaderData.users}
/>
</div>
<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>
)}
</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>
<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">
<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">
<p className="uppercase text-xs font-bold">Addresses</p>
<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" />
@@ -113,9 +286,63 @@ export default function Page({ loaderData }: Route.ComponentProps) {
</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>
<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 className="uppercase text-xs font-bold pb-2">Last Seen</th>
<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
@@ -124,24 +351,37 @@ export default function Page({ loaderData }: Route.ComponentProps) {
'border-t border-headplane-100 dark:border-headplane-800',
)}
>
{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}
/>
))}
{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>
</>
+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',
},
});
}
+32 -1
View File
@@ -1,4 +1,5 @@
import { type } from 'arktype';
import log from '~/utils/log';
import DockerIntegration from './integration/docker';
import KubernetesIntegration from './integration/kubernetes';
import ProcIntegration from './integration/proc';
@@ -14,7 +15,9 @@ export const pathSupportedKeys = [
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/"',
info_secret: 'string?',
cookie_secret: '(32 <= string <= 32)',
cookie_secure: 'boolean = true',
@@ -25,7 +28,9 @@ const serverConfig = type({
const partialServerConfig = type({
host: 'string.ip?',
port: 'number.integer?',
base_url: 'string.url?',
data_path: 'string.lower?',
info_secret: 'string?',
cookie_secret: '(32 <= string <= 32)?',
cookie_secure: 'boolean?',
@@ -62,7 +67,32 @@ const oidcConfig = type({
client_id: 'string',
client_secret: 'string',
headscale_api_key: 'string',
redirect_uri: 'string.url?',
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"',
@@ -84,6 +114,7 @@ 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?',
+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 {}
}
}
+1
View File
@@ -83,6 +83,7 @@ const appLoadContext = {
integration: await loadIntegration(config.integration),
oidcConnector: config.oidc
? await createOidcConnector(
config.server.base_url,
config.oidc,
hsApi.getRuntimeClient(config.oidc.headscale_api_key),
)
+19 -2
View File
@@ -25,6 +25,7 @@ export type OidcConnector =
| {
isValid: true;
isExclusive: boolean;
usePKCE: boolean;
client: oidc.Configuration;
apiKey: string;
scope: string;
@@ -40,16 +41,23 @@ export type OidcConnector =
* 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> {
// TODO: MEANINGFUL LOGS NOT JUST DEBUG SPAM LOL
//
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');
@@ -89,6 +97,7 @@ export async function createOidcConnector(
return {
isValid: true,
isExclusive: config.disable_api_key_login,
usePKCE: config.use_pkce,
client: oidcClientOrErrors,
apiKey: config.headscale_api_key,
scope: config.scope,
@@ -113,6 +122,13 @@ async function discoveryCoalesce(
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');
@@ -184,6 +200,7 @@ async function discoveryCoalesce(
const oidcClient = new oidc.Configuration(
{
...metadata,
issuer: config.issuer,
authorization_endpoint,
token_endpoint,
+49
View File
@@ -0,0 +1,49 @@
import { createCookie } from 'react-router';
import type { HeadplaneConfig } from '~/server/config/config-schema';
export interface OidcStateCookie {
nonce: string;
state: string;
verifier: string;
redirect_uri: string;
}
export function createOidcStateCookie(config: HeadplaneConfig) {
const cookie = createCookie('__oidc_state', {
httpOnly: true,
maxAge: 1800,
secure: config.server.cookie_secure,
domain: config.server.cookie_domain,
path: `${__PREFIX__}/oidc/callback`,
});
return {
...cookie,
serialize: async (value: OidcStateCookie): Promise<string> => {
return cookie.serialize(value);
},
parse: async (
cookieHeader: string | null,
): Promise<OidcStateCookie | null> => {
const parsed = await cookie.parse(cookieHeader);
if (
parsed == null ||
typeof parsed !== 'object' ||
typeof parsed.nonce !== 'string' ||
typeof parsed.state !== 'string' ||
typeof parsed.verifier !== 'string' ||
typeof parsed.redirect_uri !== 'string'
) {
return null;
}
return {
nonce: parsed.nonce,
state: parsed.state,
verifier: parsed.verifier,
redirect_uri: parsed.redirect_uri,
};
},
};
}
+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`;
}
+21 -7
View File
@@ -4,6 +4,11 @@ server:
host: "0.0.0.0"
port: 3000
# The base URL for Headplane. Please keep in mind that this will be required
# for Headscale to properly function going forward AND it should not include
# the dashboard prefix (/admin) portion.
base_url: "http://localhost:3000"
# The secret used to encode and decode web sessions (must be 32 characters)
# You may also provide `cookie_secret_path` instead to read a value from disk.
# See https://headplane.net/configuration/#sensitive-values
@@ -30,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:
@@ -180,16 +195,15 @@ integration:
# See https://headplane.net/configuration/#sensitive-values
# client_secret: "<your-client-secret>"
# Whether to use PKCE when authenticating users. This is recommended as it
# adds an extra layer of security to the authentication process. Enabling this
# means your OIDC provider must support PKCE and it must be enabled on the
# client.
# use_pkce: true
# If you want to disable traditional login via Headscale API keys
# disable_api_key_login: false
# Optional, but highly recommended otherwise Headplane
# will attempt to automatically guess this from the issuer
#
# This should point to your publicly accessibly URL
# for your Headplane instance with /admin/oidc/callback
# redirect_uri: "http://localhost:3000/admin/oidc/callback"
# By default profile pictures are pulled from the OIDC provider when
# we go to fetch the userinfo endpoint. Optionally, this can be set to
# "oidc" or "gravatar" as of 0.6.1.
+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' },
+111 -1
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,4 +12,113 @@ description: Configure Single Sign-On (SSO) authentication for Headplane.
<figcaption>SSO Configuration Page</figcaption>
</figure>
TODO
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.
## 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.
### Requirements
::: 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.
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.
:::
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).
### 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.
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
```
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
### 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"
# 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: ""
# token_endpoint: ""
# userinfo_endpoint: ""
# scope: "openid email profile"
# extra_params:
# foo: "bar"
# 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. *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
oidc:
use_pkce: true
```
## Troubleshooting
Some of the common issues you may encounter when configuring OIDC with Headplane
include:
- **Invalid API Key**: Ensure that the API key provided to Headplane is valid
and has not expired.
- **Missing [some]_endpoint**: If your IdP does not provide standard OIDC
endpoints, you may need to manually specify them in the Headplane configuration.
- **Missing the `sub` claim**: Ensure that your IdP is configured to include the
`sub` claim in the ID token, as this is required for Headplane to identify users.
- **Redirect URI Mismatch**: Ensure that the redirect URI configured in your IdP
and that `server.base_url` in Headplane match exactly.
- **Cookie Issues**: The OIDC authentication relies on your cookie configuration
for Headplane. If OIDC cannot complete due to a missing session or invalid
session then please check your cookie settings.
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": 1765457389,
"narHash": "sha256-ddhDtNYvleZeYF7g7TRFSmuQuZh7HCgqstg5YBGwo5s=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "1d4c88323ac36805d09657d13a5273aea1b34f0c",
"rev": "f997fa0f94fb1ce55bccb97f60d41412ae8fde4c",
"type": "github"
},
"original": {